EADly already has the basics on two pages. The OPT unemployment days calculator covers the 90- and 150-day limits, how the days add up, and a worked example (Priya: 47 days used, 43 left). The SEVP Portal guide covers how SEVIS counts unemployment, why employer dates matter, and what happens if you go over. Read those first.
This post covers what those pages don't: the edge cases people get wrong, three more worked examples, a step-by-step audit, and a small open-source script (Python and JavaScript, with tests) that counts the days the same way the calculator does.
Not legal advice. This article summarizes official U.S. government sources. It is not legal or immigration advice. EADly is not a law firm and is not affiliated with USCIS, DHS or ICE. Confirm your situation with your DSO or a qualified immigration attorney. Last checked October 17, 2026 (PT). Each source's own date is in the Sources list.
The rule in two lines
- Post-completion OPT: no more than 90 days of unemployment in total. With a 24-month STEM extension: no more than 150 days in total, counting the post-completion OPT period and the extension together (8 CFR 214.2(f)(10)(ii)(E)).
- SEVP describes the 150 as "90 days during the initial period of post-completion OPT plus an additional 60 days during the extension period" (Study in the States, STEM OPT reporting requirements).
Everything else about the limits, and the calculator, is on /opt-unemployment-days/.
Edge cases that change your count
Part-time and variable hours: the 20 hours can average out
SEVP's OPT policy guidance says: "A student must work at least 20 hours per week in a qualifying position to be considered employed. If a student has a variable schedule, within a month, it should average out to at least 20 hours per week." (§7.1.5, SEVP Policy Guidance 1004-03, April 23, 2010)
So one short week does not automatically make you unemployed, as long as the month averages 20 hours or more. Keep timesheets or pay stubs that show the hours.
ICE says this 2010 guidance "remains in effect," except where its 17-month STEM content conflicts with the 2016 STEM rule (ICE, Practical Training). This post uses it only for the 20-hour, start-date, replacement-EAD and weekend rules.
Flag: several part-time jobs on regular OPT. Some school pages say you can reach 20 hours a week on post-completion OPT by combining part-time jobs. We did not find an official sentence that says so. If you rely on combined hours, ask your DSO first.
Volunteer or unpaid work can count
"A student may work as a volunteer or unpaid intern, as long as this practice does not violate any labor laws, and the employment is related to the program of study. The student must work at least 20 hours per week." (USCIS Policy Manual, Vol. 2, Pt. F, Ch. 5)
Practical training must be "directly related to a student's major area of study" (same chapter). Keep a letter from the organization describing your role and hours.
STEM OPT: 20 hours with each employer, not combined
On STEM OPT you must "be employed for no less than 20 hours per week by each qualifying STEM OPT employer" (USCIS Policy Manual, Ch. 5). Two 10-hour STEM jobs do not meet the rule. If you start with a new employer during STEM OPT, you must submit a new Form I-983 to your DSO within 10 days of starting (USCIS Policy Manual, Ch. 5; see EADly's Form I-983 guide).
When the count starts, and the replacement-EAD exception
- "OPT authorization begins on the employment start date shown on the student's EAD." (§7.1.6, SEVP Policy Guidance 1004-03) Days between graduation and that date are not unemployment days.
- If your approved EAD never arrives and you apply for a replacement, the same guidance says: "The 'clock' for unemployment starts after issuance of the replacement EAD by USCIS." (§7.1.7, same document) Keep the replacement notice. If you are in this situation, ask your DSO how it will appear in SEVIS.
Cap-gap days count
"Unemployment during the cap-gap automatic extension counts toward the 90-day (post-completion OPT) or 150-day (for STEM OPT extension) cumulative maximum allowed unemployment." (USCIS Policy Manual, Ch. 5) Cap-gap itself is covered in our F-1 to H-1B post.
Unemployment doesn't use up OPT, and doesn't extend it
- "Overtime and periods of unemployment are not considered when calculating the amount of OPT used." (USCIS Policy Manual, Ch. 5)
- "STEM OPT is not extended by periods of part-time employment or unemployment." (same chapter)
Your EAD end date stays the same however many days you were unemployed.
Trips abroad
Time abroad while unemployed counts, and the 2010 guidance lists two exceptions (employer-authorized leave, work travel). EADly's Travel on OPT guide covers this with sources. Example C below shows the math.
While a STEM OPT application is pending
The regulation applies the 150-day limit to "students granted a 24-month OPT extension" (8 CFR 214.2(f)(10)(ii)(E)). The Policy Manual says "an F-1 student approved for STEM OPT may not accrue an aggregate of more than 150 days" (Ch. 5). If you filed for STEM OPT on time and your OPT EAD expires while the application is pending, EADly's browser calculator and the script below apply the 150-day limit for that period, for up to 180 days (8 CFR 274a.12(b)(6)(iv)). The unemployment days you used during OPT count toward the 150; the count doesn't restart. Confirm with your DSO.
Worked examples
All examples are fictional. Example A (Priya, two jobs, 47 days) is on the calculator page.
Example B: a weekend between jobs
A job ends Friday, July 10, 2026. The next starts Monday, July 13, 2026. Saturday and Sunday count, because SEVP counts "each day (including weekends)" without qualifying employment (§7.1.6, SEVP Policy Guidance 1004-03). Total: 2 days. The last day of the old job and the first day of the new one count as days worked.
Example C: a trip while unemployed
Between jobs from August 15 to 31, 2026, Priya flies home for the whole gap. Her count for that stretch is still 17 days. "Time spent overseas counts towards the F-1 student's aggregate maximum allowed period of unemployment" (USCIS Policy Manual, Vol. 2, Pt. F, Ch. 7). The trip doesn't pause the count, and it doesn't add time to her OPT.
Example D: days carry from OPT into STEM OPT
An OPT EAD starts July 1, 2025. The first job starts September 19, 2025, so 80 days are used. STEM OPT starts July 1, 2026, and the student has no job in July.
- June 30, 2026: 80 used, limit 90, 10 left.
- July 31, 2026: 80 + 31 = 111 used, limit 150, 39 left.
The 80 days carry into STEM OPT. The count never resets.
Audit your count in six steps
- Get your EAD dates from the card: "Valid From" and "Card Expires". For STEM OPT, note the STEM EAD dates too. In cap-gap, use the date on your updated I-20.
- List every job: employer, start date, end date (blank if ongoing), hours and whether it relates to your degree. On STEM OPT, note whether it's the E-Verify employer on your I-983.
- Count the uncovered days from the EAD start date to today, or to your authorization end if that's earlier. Weekends count. A job's first and last days count as worked. The calculator or the script below does this.
- Compare to the limit: 90, or 150 once STEM OPT has started or while a timely STEM OPT application is pending (see the note above).
- Check what SEVIS has. Your reported employer dates drive the official count. How to check and fix them is on /sevp-portal/. Only your DSO can see the SEVIS unemployment counter (Study in the States, Unemployment Counter).
- If you are close to the limit, talk to your DSO or an immigration attorney now. Days already counted stay counted.
[SCREENSHOT NEEDED: public page https://eadly.app/opt-unemployment-days/, calculator with Example D entered (EAD Jul 1, 2025; STEM start Jul 1, 2026; job Sep 19, 2025 to Jun 30, 2026; count through Jul 31, 2026) showing 111 used]
A day counter you can run
The script counts dates the same way as the EADly calculator:
- count from the EAD start date and ignore days before it;
- count every calendar day, weekends included, that no qualifying job covers;
- treat a job's start and end dates as days worked;
- ignore jobs you mark as not qualifying, and never double count overlapping jobs;
- stop at the earlier of the as-of date and your authorization end (OPT EAD end, STEM EAD end, the cap-gap date on your I-20, or up to 180 days past the OPT EAD end while a timely STEM application is pending);
- use 150 as the limit once your STEM OPT start date has passed, and never reset the count.
It counts dates only. It does not decide whether a job qualifies or whether you are in status.
Files (MIT license, no dependencies): opt_counter.py, test_opt_counter.py, opt_counter.js, opt_counter.test.js. Download: opt_counter.py · test_opt_counter.py · opt_counter.js · opt_counter.test.js.
Python (3.8+)
python3 opt_counter.py --ead-start 2025-07-01 --auth-end 2028-06-30 \
--stem-start 2026-07-01 --as-of 2026-07-31 --job 2025-09-19:2026-06-30
Output:
Unemployment days used: 111
Limit: 150
Days remaining: 39
First day over limit: none
unemployed 2025-07-01 to 2025-09-18 (80 days)
unemployed 2026-07-01 to 2026-07-31 (31 days)
Counts dates only. It does not decide whether a job qualifies or whether you are in status.
- Leave the end date empty (
2026-09-01:) for a job you still have. - Add
:no(2026-06-01:2026-08-31:no) for a job that doesn't qualify. - Add
--stem-startonce STEM OPT is approved. - If your OPT EAD has expired and a timely STEM application is pending, keep
--auth-endas your OPT EAD end date and add--stem-pending. The script counts up to 180 days past that date against 150 (see the note above).
#!/usr/bin/env python3
"""OPT / STEM OPT unemployment day counter. MIT License. No dependencies.
Counts every calendar day (weekends included) from the EAD start date to the
earlier of the as-of date and the authorization end that no qualifying job
covers. A job's start and end dates count as days worked.
Example:
python3 opt_counter.py --ead-start 2026-06-01 --auth-end 2027-05-31 \
--as-of 2026-09-30 --job 2026-07-01:2026-08-14 --job 2026-09-01:
"""
import argparse
from datetime import date, timedelta
DISCLAIMER = ("Counts dates only. It does not decide whether a job qualifies "
"or whether you are in status.")
ONE_DAY = timedelta(days=1)
def count_unemployment(ead_start, auth_end, as_of, jobs, stem_start=None):
"""jobs: list of {"start": date, "end": date | None, "qualifying": bool}.
end=None means the job is ongoing."""
count_to = min(as_of, auth_end)
covered = set() # a set, so overlapping jobs never double count
for job in jobs:
if not job["qualifying"]:
continue
d = max(job["start"], ead_start)
end = count_to if job["end"] is None else min(job["end"], count_to)
while d <= end:
covered.add(d)
d += ONE_DAY
unemployed_dates = []
first_day_over_limit = None
d = ead_start
while d <= count_to:
if d not in covered:
unemployed_dates.append(d)
limit_that_day = 150 if stem_start and d >= stem_start else 90
if first_day_over_limit is None and len(unemployed_dates) > limit_that_day:
first_day_over_limit = d
d += ONE_DAY
limit = 150 if stem_start and count_to >= stem_start else 90
return {
"days_used": len(unemployed_dates),
"limit": limit,
"days_remaining": limit - len(unemployed_dates),
"first_day_over_limit": first_day_over_limit,
"unemployed_dates": unemployed_dates,
}
def to_ranges(dates):
"""[Jun 1, Jun 2, Jun 5] -> [(Jun 1, Jun 2), (Jun 5, Jun 5)]"""
ranges = []
for d in dates:
if ranges and ranges[-1][1] + ONE_DAY == d:
ranges[-1][1] = d
else:
ranges.append([d, d])
return ranges
def stem_pending_window(opt_end, as_of):
"""A timely STEM OPT application is still pending. Like EADly, counting runs up to
180 days past the OPT EAD end date (8 CFR 274a.12(b)(6)(iv)) and the 150-day limit
applies from the next day. Returns (auth_end, stem_start); after day 180 the count
falls back to the OPT EAD end date and the 90-day limit, as in EADly."""
pending_end = opt_end + timedelta(days=180)
if as_of <= pending_end:
return pending_end, opt_end + ONE_DAY
return opt_end, None
def parse_job(text):
"""START:END[:no] END may be empty (ongoing). ':no' = not qualifying."""
parts = text.split(":")
if len(parts) not in (2, 3) or (len(parts) == 3 and parts[2] != "no"):
raise argparse.ArgumentTypeError(f"bad --job {text!r}, use START:END or START:END:no")
return {
"start": date.fromisoformat(parts[0]),
"end": date.fromisoformat(parts[1]) if parts[1] else None,
"qualifying": len(parts) == 2,
}
def main():
p = argparse.ArgumentParser(description="Count OPT / STEM OPT unemployment days.")
p.add_argument("--ead-start", type=date.fromisoformat, required=True,
help="'Valid From' date on your OPT EAD (YYYY-MM-DD)")
p.add_argument("--auth-end", type=date.fromisoformat, required=True,
help="last authorized date: OPT EAD end, STEM EAD end, or cap-gap date on your I-20")
p.add_argument("--as-of", type=date.fromisoformat, default=date.today(),
help="count through this date (default: today)")
p.add_argument("--job", type=parse_job, action="append", default=[],
help="START:END (END empty if ongoing); add ':no' if not qualifying. Repeatable.")
p.add_argument("--stem-start", type=date.fromisoformat,
help="'Valid From' date on your STEM OPT EAD, once approved")
p.add_argument("--stem-pending", action="store_true",
help="you filed for STEM OPT by your OPT EAD end date (--auth-end) "
"and are waiting for a decision")
a = p.parse_args()
auth_end, stem_start = a.auth_end, a.stem_start
if a.stem_pending and not stem_start:
auth_end, stem_start = stem_pending_window(a.auth_end, a.as_of)
r = count_unemployment(a.ead_start, auth_end, a.as_of, a.job, stem_start)
print(f"Unemployment days used: {r['days_used']}")
print(f"Limit: {r['limit']}")
if a.stem_pending and not a.stem_start:
if stem_start:
print("STEM OPT application pending: 150-day limit after your OPT EAD end date, "
"for up to 180 days, including the days used during OPT. Confirm with your DSO.")
else:
print(f"The 180-day pending period ended on {a.auth_end + timedelta(days=180)}. "
"Once STEM OPT is approved, use --stem-start. Confirm with your DSO.")
print(f"Days remaining: {r['days_remaining']}")
print(f"First day over limit: {r['first_day_over_limit'] or 'none'}")
for start, end in to_ranges(r["unemployed_dates"]):
n = (end - start).days + 1
print(f" unemployed {start} to {end} ({n} day{'s' if n > 1 else ''})")
print(DISCLAIMER)
if __name__ == "__main__":
main()
JavaScript (Node 18+ or the browser)
Dates are "YYYY-MM-DD" strings, turned into whole UTC day numbers, so a daylight-saving change can't shift a day.
// OPT / STEM OPT unemployment day counter. MIT License. No dependencies.
// Same rules as opt_counter.py. Dates are "YYYY-MM-DD" strings, turned into
// whole UTC day numbers from their Y-M-D integers, so DST can never shift a day.
const DISCLAIMER =
"Counts dates only. It does not decide whether a job qualifies or whether you are in status.";
function dayNum(iso) {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
if (!m) throw new Error(`Expected YYYY-MM-DD, got ${iso}`);
const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
const ms = Date.UTC(y, mo - 1, d);
const check = new Date(ms);
if (check.getUTCMonth() !== mo - 1 || check.getUTCDate() !== d) throw new Error(`Not a real date: ${iso}`);
return ms / 86400000;
}
function isoOf(n) {
return new Date(n * 86400000).toISOString().slice(0, 10);
}
// jobs: [{ start: "YYYY-MM-DD", end: "YYYY-MM-DD" | null, qualifying: true | false }]
// end = null means the job is ongoing.
function countUnemployment(eadStart, authEnd, asOf, jobs, stemStart = null) {
const ead = dayNum(eadStart);
const countTo = Math.min(dayNum(asOf), dayNum(authEnd));
const stem = stemStart ? dayNum(stemStart) : null;
const covered = new Set(); // a Set, so overlapping jobs never double count
for (const job of jobs) {
if (!job.qualifying) continue;
const end = job.end == null ? countTo : Math.min(dayNum(job.end), countTo);
for (let d = Math.max(dayNum(job.start), ead); d <= end; d++) covered.add(d);
}
const unemployed = [];
let firstOver = null;
for (let d = ead; d <= countTo; d++) {
if (covered.has(d)) continue;
unemployed.push(d);
const limitThatDay = stem !== null && d >= stem ? 150 : 90;
if (firstOver === null && unemployed.length > limitThatDay) firstOver = d;
}
const limit = stem !== null && countTo >= stem ? 150 : 90;
return {
days_used: unemployed.length,
limit,
days_remaining: limit - unemployed.length,
first_day_over_limit: firstOver === null ? null : isoOf(firstOver),
unemployed_dates: unemployed.map(isoOf),
};
}
module.exports = { countUnemployment, DISCLAIMER };
const { countUnemployment } = require("./opt_counter.js");
countUnemployment("2026-06-01", "2027-05-31", "2026-09-30", [
{ start: "2026-07-01", end: "2026-08-14", qualifying: true },
{ start: "2026-09-01", end: null, qualifying: true },
]);
// -> { days_used: 47, limit: 90, days_remaining: 43, first_day_over_limit: null, ... }
In a browser, delete the module.exports line.
Tests
Run python3 -m unittest test_opt_counter.py and node --test opt_counter.test.js. Both pass on the cases below. The values were also checked against an independent reference implementation and 400 random cases.
| Case | Inputs | Used | Limit | Left | First day over |
|---|---|---|---|---|---|
| Priya | EAD 2026-06-01 to 2027-05-31, as of 2026-09-30, jobs Jul 1–Aug 14 and Sep 1–ongoing | 47 | 90 | 43 | none |
| No job | EAD 2026-06-01, as of 2026-06-30 | 30 | 90 | 60 | none |
| Weekend gap | Jobs Jun 1–Jul 10 (Fri) and Jul 13 (Mon)–ongoing, as of 2026-07-31 | 2 | 90 | 88 | none |
| Non-qualifying job | Job Jun 1–ongoing marked not qualifying, as of 2026-06-30 | 30 | 90 | 60 | none |
| Overlapping jobs | Jun 5–20 and Jun 10–25, as of 2026-06-30 | 9 | 90 | 81 | none |
| Job before EAD | Job May 1–Jun 10, EAD Jun 1, as of 2026-06-30 | 20 | 90 | 70 | none |
| Before STEM starts | EAD 2025-07-01, auth to 2028-06-30, STEM from 2026-07-01, job 2025-09-19 to 2026-06-30, as of 2026-06-30 | 80 | 90 | 10 | none |
| After STEM starts | Same, as of 2026-07-31 | 111 | 150 | 39 | none |
| Over the limit | EAD 2026-06-01, no job, as of 2026-08-31 | 92 | 90 | −2 | 2026-08-30 |
| Checked after auth ends | EAD 2025-06-01 to 2026-05-31, job to 2026-04-30, as of 2026-09-30 | 31 | 90 | 59 | none |
| Cap-gap | EAD 2025-06-01, cap-gap auth to 2026-09-30, job to 2026-04-30, as of 2026-07-15 | 76 | 90 | 14 | none |
| Leap day | EAD 2028-02-01, no job, as of 2028-02-29 | 29 | 90 | 61 | none |
Keeping the count in EADly
If you'd rather not keep a spreadsheet, EADly does this count on your phone. It's a private, local-first app for iPhone and Android:
- OPT/STEM unemployment counter, worked out from the authorization dates and jobs you save. EADly Free saves every F-1 job, so the count can be complete.
- Unemployment Forecast card with a "What if your job ends on…" field, for planning a job change.
- Employment-end review dates and Training Timeline milestones.
[SCREENSHOT NEEDED: in-app screen, Home → Unemployment Forecast card with sample data from Example D, showing days used and days remaining]
[SCREENSHOT NEEDED: in-app screen, Unemployment Forecast → "What if your job ends on…" with a sample end date entered]
[SCREENSHOT NEEDED: in-app screen, Employment list with two fictional sample jobs and the employment-end review date]
[SCREENSHOT NEEDED: in-app screen, Training Timeline milestones (OPT start, STEM OPT application, OPT end, STEM OPT start)]
What it doesn't do: EADly counts dates. It doesn't decide whether a job qualifies or whether you are in status, and it doesn't report anything to your DSO, SEVIS or USCIS. You still report through the SEVP Portal or your DSO. Quick Actions → Reporting & SEVP opens the official reporting tools.
[SCREENSHOT NEEDED: in-app screen (optional), Quick Actions → "Reporting & SEVP"]
Get EADly on the App Store or Google Play, or use the free browser calculator with no download.
Common questions
Can part-time weeks count as employed?
SEVP's 2010 guidance says a variable schedule should "average out to at least 20 hours per week" within a month (§7.1.5, SEVP Policy Guidance 1004-03).
Does volunteering count as employment on OPT?
Yes, if it's lawful, related to your program of study and at least 20 hours a week (USCIS Policy Manual, Ch. 5).
Can I combine two STEM OPT jobs to reach 20 hours?
No. STEM OPT requires at least 20 hours a week with each qualifying employer (USCIS Policy Manual, Ch. 5).
Do cap-gap days count as unemployment?
Yes. Unemployment during the cap-gap extension counts toward the 90 or 150 days (USCIS Policy Manual, Ch. 5).
Does being unemployed shorten or extend my OPT?
Neither. Unemployment isn't counted as OPT used, and STEM OPT isn't extended by unemployment (USCIS Policy Manual, Ch. 5).
Which limit applies while my STEM OPT application is pending?
EADly applies the 150-day limit while a timely STEM OPT application is pending, for up to 180 days after your OPT EAD expires. The days you used during OPT count toward the 150. Confirm with your DSO.
Sources
All checked October 17, 2026 (PT). The date in brackets is the date each page showed.
- 8 CFR 214.2(f)(10)(ii)(E), eCFR as of Sept 14, 2026: https://www.ecfr.gov/on/2026-09-14/title-8/chapter-I/subchapter-B/part-214/subpart-A/section-214.2 [the cited text is identical in the eCFR version of Oct 14, 2026. We cite the Sept 14 version because USCIS says it is following the previous provisions after a court postponed the July 17, 2026 rule: https://www.uscis.gov/i-765]
- USCIS Policy Manual, Vol. 2, Pt. F, Ch. 5: https://www.uscis.gov/policy-manual/volume-2-part-f-chapter-5 [current as of October 2, 2026]
- USCIS Policy Manual, Vol. 2, Pt. F, Ch. 7: https://www.uscis.gov/policy-manual/volume-2-part-f-chapter-7 [current as of October 2, 2026]
- SEVP Policy Guidance 1004-03, Update to Practical Training: https://www.ice.gov/doclib/sevis/pdf/opt_policy_guidance_042010.pdf [April 23, 2010]
- ICE, Practical Training: https://www.ice.gov/sevis/practical-training
- Study in the States, STEM OPT reporting requirements (students): https://studyinthestates.dhs.gov/stem-opt-hub/for-students/students-stem-opt-reporting-requirements [July 6, 2026]
- Study in the States, Unemployment Counter: https://studyinthestates.dhs.gov/sevis-help-hub/student-records/fm-student-employment/unemployment-counter [July 27, 2026]
This article summarizes official U.S. government sources and is not legal or immigration advice. EADly is not a law firm and is not affiliated with USCIS, DHS or ICE.