I run a small script that rewrites my app's store descriptions into each language. Last week I opened its config file, and I could not remember the last time I had looked at the model ID written inside it.
Some files you never open while things keep working. For me, this was one of them.
So I went to the official deprecations page — and what I found there disagreed, in several places, with the write-ups I had read beforehand. What follows is that disagreement, and the small stocktaking routine I built so I would not check this way again.
The October 16 date is not on the official table
Let me put the conclusion first. You will see it written in several places that Gemini 2.5 Pro, 2.5 Flash and 2.5 Flash-Lite will be retired on October 16, 2026. On the Gemini API deprecations page, all three are listed as "No shutdown date announced." The October 16 date does not appear anywhere on that table.
The dates that are actually listed nearby belong to other models. Here is what I could confirm on September 14, 2026.
| Model ID | Earliest shutdown date | Recommended replacement |
|---|---|---|
gemini-omni-flash-preview | 2026-09-30 | gemini-omni-1.1-flash |
gemini-2.5-flash-image | 2026-10-02 | gemini-3.1-flash-image-preview |
gemini-3.1-flash-lite | 2027-05-07 | gemini-3.5-flash-lite |
gemini-2.5-pro | Not announced | — |
gemini-2.5-flash | Not announced | — |
gemini-2.5-flash-lite | Not announced | — |
There is one explanation I find plausible for the mismatch. The Gemini API and the Gemini Enterprise Agent Platform on Google Cloud maintain separate deprecation schedules. The October 16 date may well come from the latter. I have not been able to confirm that table against a primary source, so I will not state it as fact.
One line does come out of this clearly, though. Decide which path you are calling through, then read the table for that path. Skip that step, and you end up rearranging your work around a deadline that may not apply to you — which is where I was for about an hour.
"No shutdown date" does not mean "safe for now"
There is a second thing worth carrying with you when you read that table. The page notes that the listed dates are the earliest possible dates a model might be retired, and that the exact date will be communicated separately with advance notice.
So the table is not a schedule of when things stop. It is a floor — a promise that nothing stops sooner. What belongs in your calendar is the margin in front of the date, not the date itself.
The trap runs the other way too. "Not announced" is not a guarantee of availability. The developer forum carries reports of a model being deprecated ahead of its published shutdown date and of a model no longer being offered to new users. Your existing calls continuing to work, and someone else being able to reproduce your setup tomorrow, are two different questions.
For a while I tried to remember the dates. That did not work well. The table gets updated; my memory does not. What I keep now is not the dates but the place and the procedure for reconciling them.
Pull every model ID out of the repository
Step one is the inventory. Model IDs rarely sit in one place. There is the fallback, the test mock, the sample in the README, the shell script nobody has touched in a while — they scatter, and then they are forgotten.
So I keep a small script that walks a directory and collects them.
#!/usr/bin/env python3
"""Collect every model ID referenced anywhere in a repository."""
import argparse
import json
import pathlib
import re
import sys
# Kept deliberately loose so that new naming generations still match.
MODEL_RE = re.compile(
r"\b(?:gemini|imagen|veo|lyria|text-embedding|embedding)[a-z0-9.\-]*\b",
re.IGNORECASE,
)
SKIP_DIRS = {".git", "node_modules", ".next", "dist", "build", "__pycache__", ".venv"}
SCAN_SUFFIXES = {
".py", ".js", ".ts", ".tsx", ".jsx", ".mjs", ".go", ".rb", ".sh",
".json", ".yaml", ".yml", ".toml", ".env", ".md", ".txt",
}
def iter_files(root: pathlib.Path):
for path in root.rglob("*"):
if not path.is_file():
continue
if SKIP_DIRS & set(path.parts):
continue
if path.suffix.lower() not in SCAN_SUFFIXES:
continue
yield path
def collect(root: pathlib.Path) -> dict:
found: dict[str, list[str]] = {}
for path in iter_files(root):
try:
text = path.read_text(encoding="utf-8", errors="ignore")
except OSError:
continue
for lineno, line in enumerate(text.splitlines(), 1):
for hit in MODEL_RE.findall(line):
model = hit.lower().rstrip(".-")
# Drop bare words like "gemini" that carry no version.
if "-" not in model and "." not in model:
continue
found.setdefault(model, []).append(f"{path.relative_to(root)}:{lineno}")
return found
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("root", help="directory to scan")
ap.add_argument("--json", action="store_true", help="emit JSON")
args = ap.parse_args()
root = pathlib.Path(args.root).resolve()
found = collect(root)
if args.json:
print(json.dumps(found, ensure_ascii=False, indent=2, sort_keys=True))
return 0
if not found:
print("no model IDs found")
return 0
for model in sorted(found):
places = found[model]
print(f"{model} ({len(places)} hits)")
for place in places[:3]:
print(f" {place}")
if len(places) > 3:
print(f" ... and {len(places) - 3} more")
return 0
if __name__ == "__main__":
sys.exit(main())The loose regular expression is deliberate. Write something strict like gemini-\d+\.\d+-(pro|flash) and the day a naming convention shifts, the script quietly matches nothing at all. A stocktaking tool that fails silently is the worst failure mode available to it. Better to over-collect and drop the version-less words afterwards.
When you try it, build a tiny directory with known IDs first and confirm they come back.
python3 model_inventory.py ./samplegemini-2.0-flash-001 (1 hits)
src/app.py:2
gemini-2.5-flash (1 hits)
src/app.py:1
gemini-2.5-flash-image (1 hits)
conf/agent.yaml:2
gemini-3.5-flash (1 hits)
conf/agent.yaml:1
text-embedding-004 (1 hits)
src/app.py:3Reconcile the IDs against a ledger you own
Step two is the reconciliation. I deliberately do not fetch and parse the official page at runtime. The day its markup changes, the stocktaking would break before anything else does. Instead I copy the table into a CSV I keep, and take responsibility for when I last copied it.
model,shutdown,replacement
gemini-3.8-flash,,
gemini-3.5-flash,,
gemini-2.5-pro,,
gemini-2.5-flash,,
gemini-2.5-flash-image,2026-10-02,gemini-3.1-flash-image-preview
gemini-2.0-flash-001,2026-06-01,gemini-3.6-flash
gemini-omni-flash-preview,2026-09-30,gemini-omni-1.1-flash
text-embedding-004,2026-01-14,gemini-embedding-2
gemini-embedding-001,2028-05-14,gemini-embedding-2An empty cell means "not announced." I keep that distinct from a row that is missing entirely.
#!/usr/bin/env python3
"""Reconcile collected model IDs against a locally maintained ledger."""
import argparse
import csv
import datetime as dt
import json
import pathlib
import sys
SOON_DAYS = 60
def load_table(path: pathlib.Path) -> dict:
table = {}
with path.open(encoding="utf-8", newline="") as fh:
for row in csv.DictReader(fh):
model = (row.get("model") or "").strip()
if not model:
continue
raw = (row.get("shutdown") or "").strip()
table[model] = {
"shutdown": dt.date.fromisoformat(raw) if raw else None,
"replacement": (row.get("replacement") or "").strip(),
}
return table
def classify(model: str, table: dict, today: dt.date) -> tuple[str, str]:
if model not in table:
return "UNLISTED", "not in the ledger - check the primary table"
entry = table[model]
shutdown = entry["shutdown"]
if shutdown is None:
return "NO_DATE", "no shutdown date announced (not a guarantee of availability)"
days = (shutdown - today).days
repl = f" -> {entry['replacement']}" if entry["replacement"] else ""
if days < 0:
return "PAST", f"earliest shutdown {shutdown} has passed{repl}"
if days <= SOON_DAYS:
return "SOON", f"{shutdown} ({days} days away){repl}"
return "SCHEDULED", f"{shutdown} ({days} days away){repl}"
ORDER = ["PAST", "SOON", "UNLISTED", "SCHEDULED", "NO_DATE"]
FAIL_ON = {"PAST", "SOON", "UNLISTED"}
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("inventory", help="output of model_inventory.py --json")
ap.add_argument("table", help="deprecation ledger CSV")
ap.add_argument("--today", default=None, help="reference date (defaults to today)")
args = ap.parse_args()
today = dt.date.fromisoformat(args.today) if args.today else dt.date.today()
inventory = json.loads(pathlib.Path(args.inventory).read_text(encoding="utf-8"))
table = load_table(pathlib.Path(args.table))
buckets: dict[str, list[str]] = {key: [] for key in ORDER}
for model, places in sorted(inventory.items()):
status, note = classify(model, table, today)
where = places[0] if places else "?"
buckets[status].append(
f" {model:<32} {note} [{where}, {len(places)} hits]"
)
exit_code = 0
for key in ORDER:
if not buckets[key]:
continue
print(f"[{key}]")
print("\n".join(buckets[key]))
if key in FAIL_ON:
exit_code = 1
return exit_code
if __name__ == "__main__":
sys.exit(main())Run against the same sample, it looks like this.
python3 model_inventory.py ./sample --json > inventory.json
python3 check_shutdown.py inventory.json shutdown_dates.csv[PAST]
gemini-2.0-flash-001 earliest shutdown 2026-06-01 has passed -> gemini-3.6-flash [src/app.py:2, 1 hits]
text-embedding-004 earliest shutdown 2026-01-14 has passed -> gemini-embedding-2 [src/app.py:3, 1 hits]
[SOON]
gemini-2.5-flash-image 2026-10-02 (18 days away) -> gemini-3.1-flash-image-preview [conf/agent.yaml:2, 1 hits]
[NO_DATE]
gemini-2.5-flash no shutdown date announced (not a guarantee of availability) [src/app.py:1, 1 hits]
gemini-3.5-flash no shutdown date announced (not a guarantee of availability) [conf/agent.yaml:1, 1 hits]Two choices in there are on purpose.
The first is that an ID missing from the ledger fails the run as UNLISTED. If unknown IDs pass quietly, the stocktaking stops meaning anything. Either the ledger needs a new row or the string needs fixing, and I would rather a person decide which.
The second is that only three conditions set the exit code: already past, within sixty days, and not in the ledger. If "not announced" also failed, nearly every run would be red, and before long nobody would read it. Narrowing what stops you is what makes it stop you.
Turn it into a short monthly habit
Once the tools exist, they need a home. I settled on three decisions and resisted adding a fourth.
| Decision | What I chose | Why |
|---|---|---|
| How often to recopy the ledger | Once a month | The table moves at a pace a weekly cadence would already outrun |
| Where the reconciliation runs | Locally and in CI | Local-only gets skipped in a busy month |
| What a red run buys you | Picking a replacement the same day | The swap itself can wait; the decision is what gets forgotten |
A word on what I hand to agent-style tooling. Running the scan and the reconciliation, researching candidate replacements, assembling the list of affected call sites — I am comfortable delegating all of that. What I keep on my own desk is the choice of which table counts as the primary source. An agent will bring back a date from a search result without hesitating, and I have no way to rule out that it is a number like this October 16 one.
Let the tooling chase versions; choose the source of the deadline yourself. After this mismatch, I added that line as a comment at the top of the ledger.
If you would like the companion piece on writing calls that survive a model swap in the first place, Antigravity's default model can change without breaking the jobs whose output you narrowed first covers fixing the output shape ahead of time. Deprecation readiness is the other half of the same habit.
Start by running model_inventory.py once against your smallest live project, just to see with your own eyes what you are calling. In my case, besides the config file I could not remember, it turned up a shell script I had not touched in about two years.
Thank you for reading. If you are carrying a file you have not opened in a while, I hope this gives you a reason to open it.