Early in September I went to replace a leftover gemini-2.5-flash-image in one of my verification scripts. The Deprecations page had a replacement listed right there in the row, so I copied that string and swapped the model ID constant.
The thing I swapped to, gemini-3.1-flash-image-preview, turned out to be listed a few tables up on the same page — retired on June 25.
Reading a single row would have moved me from a model that is about to stop to one that had already stopped. A migration target isn't the string in the row; it's wherever the chain ends. Here is how I check now, with the output I actually got.
The row for October 2 points at something that stopped on June 25
Three rows from the Gemini deprecations page, side by side.
| Model ID | Released | Shutdown | Replacement listed in the table |
|---|---|---|---|
gemini-2.5-flash-image | Oct 2, 2025 | Oct 2, 2026 | gemini-3.1-flash-image-preview |
gemini-3.1-flash-image-preview | Feb 26, 2026 | Jun 25, 2026 (already gone) | gemini-3.1-flash-image |
gemini-3.1-flash-image | May 28, 2026 | No shutdown date announced | — |
The target you actually want is gemini-3.1-flash-image, the one without -preview. It went GA on May 28, 2026, and no shutdown date has been announced for it.
gemini-2.5-flash-image is the original model that spread under the name "Nano Banana," so it's probably the image endpoint that survives in the largest number of indie sample repos and blog posts. I don't use AI for the artwork or the assets I ship, but I did keep a small script around to poke at the endpoint and watch its behavior. Code like that — working, so nobody touches it — is exactly the code that misses a deadline.
One caveat belongs in every article about this page. Google states plainly that the dates in the table are the earliest possible dates a model might be retired, and that the exact date will be communicated in advance. So October 2 isn't a guarantee that things break. It also isn't a reason to treat it as soft.
Two columns tell you whether a target is alive
When I read a row now, I look at two things.
- Does the shutdown column say "No shutdown date announced"? If there's a date, that replacement will need replacing too.
- Does the model ID end in
-preview? Preview models turn over on a much shorter cycle than GA ones.
gemini-3.1-flash-image-preview failed both tests. It had a date, and it was a preview.
My guess is that each row simply keeps the information it had when the deprecation was announced. The table is written row by row, and when a replacement later retires, the older row doesn't follow it. So it's less that the page is wrong and more that a row doesn't finish the thought. Following the chain is the reader's job.
A script that follows the chain to its end
Doing this by hand is how you miss one, so I wrote a short script. Give it a saved copy of the page and it walks the replacement column until it lands on a model with no announced shutdown date. Standard library only.
#!/usr/bin/env python3
"""Follow the 'recommended replacement' column until it lands on a live model.
python3 resolve_replacement.py deprecations.md
python3 resolve_replacement.py deprecations.md gemini-2.5-flash-image
"""
import re, sys
from datetime import date, datetime
TODAY = date.today()
# | `model-id` | release date | shutdown date | `replacement` |
ROW = re.compile(
r"\|\s*`?([a-z0-9.\-]+)`?\s*\|" # model ID
r"[^|]*\|" # release date (unused)
r"\s*([^|]*?)\s*\|" # shutdown date
r"\s*`?([a-z0-9.\-]*)`?\s*\|" # recommended replacement
)
def parse_date(text):
text = text.strip()
if not text or "No shutdown" in text:
return None # no date announced = alive for now
for fmt in ("%B %d, %Y", "%b %d, %Y"):
try:
return datetime.strptime(text, fmt).date()
except ValueError:
continue
return None
def load(path):
table = {}
with open(path, encoding="utf-8") as fh:
for line in fh:
m = ROW.search(line)
if not m:
continue
model, shutdown_text, replacement = m.groups()
if model in ("Model", "Preview models", "Deprecated models"):
continue # drop headers and section dividers
table[model] = {
"shutdown": parse_date(shutdown_text),
"replacement": replacement.strip() or None,
}
return table
def resolve(table, model, seen=None):
seen = (seen or []) + [model]
row = table.get(model)
if row is None:
return model, seen, "unknown" # ID not in the table
if row["shutdown"] is None:
return model, seen, "alive" # end of the chain
nxt = row["replacement"]
if not nxt or nxt in seen: # empty target, or a loop
return model, seen, "dead-end"
return resolve(table, nxt, seen)
def status_of(table, model):
row = table.get(model)
if row is None:
return "not in the table"
if row["shutdown"] is None:
return "no shutdown date"
left = (row["shutdown"] - TODAY).days
tail = " (already gone)" if left <= 0 else " (%d days left)" % left
return "shutdown %s%s" % (row["shutdown"].isoformat(), tail)
def main():
table = load(sys.argv[1])
targets = sys.argv[2:] or sorted(
m for m, r in table.items() if r["shutdown"] and r["shutdown"] > TODAY
)
for model in targets:
listed = (table.get(model) or {}).get("replacement")
final, path, kind = resolve(table, model)
print("%-42s %s" % (model, status_of(table, model)))
print(" -> actual target: %s (%s)" % (final, kind))
if len(path) > 2:
print(" chain: " + " -> ".join(path))
if listed and listed != final:
print(" !! listed target %s is %s" % (listed, status_of(table, listed)))
print()
if __name__ == "__main__":
sys.exit(main())Pointed at the saved page, asking only about the row in question:
$ python3 resolve_replacement.py deprecations.md gemini-2.5-flash-image
gemini-2.5-flash-image shutdown 2026-10-02 (20 days left)
-> actual target: gemini-3.1-flash-image (alive)
chain: gemini-2.5-flash-image -> gemini-3.1-flash-image-preview -> gemini-3.1-flash-image
!! listed target gemini-3.1-flash-image-preview is shutdown 2026-06-25 (already gone)resolve() recurses because, as it turned out, not every chain is two links long.
Image models weren't the only ones chained
I ran the same script across every row. Among rows that have a shutdown date, 11 have a listed replacement that isn't where the chain ends, and 5 of those point at a model that has already stopped.
| Model | Replacement in the table | End of the chain | Hops |
|---|---|---|---|
gemini-2.5-flash-image | gemini-3.1-flash-image-preview (gone) | gemini-3.1-flash-image | 2 |
gemini-2.5-flash-image-preview | gemini-2.5-flash-image (Oct 2) | gemini-3.1-flash-image | 3 |
gemini-2.0-flash-preview-image-generation | gemini-2.5-flash-image (Oct 2) | gemini-3.1-flash-image | 3 |
imagen-3.0-generate-002 | imagen-4.0-generate-001 (gone) | gemini-3.1-flash-image | 2 |
gemini-2.0-flash-lite | gemini-3.1-flash-lite (May 7, 2027) | gemini-3.5-flash-lite | 2 |
gemini-robotics-er-1.5-preview | gemini-robotics-er-1.6-preview (gone) | gemini-robotics-er-2-preview | 2 |
Two of the image chains are three hops long. Start from gemini-2.0-flash-preview-image-generation in an old sample and follow it honestly: you pass through gemini-2.5-flash-image before you reach gemini-3.1-flash-image. Stop one step early and you have landed on the model that goes away in twenty days.
I should admit the first version of the script reported 16 rows, not 11. Five were noise. I was comparing rows whose shutdown column was empty — models with no retirement planned at all — and some of those, like gemini-3-flash-preview, carry a replacement anyway.
Adding one condition, "only look at rows that have a shutdown date," brought it down to 11. The condition I added to shrink the result was worth more, later, than the code that made it run at all. I left a comment explaining why the count dropped, which is the part that will help when the page's layout changes.
Ask the endpoint before you trust the page
Documentation records announcements. What actually answers is the endpoint. So the migration script starts with one check that the ID I'm about to ship is really being served.
from google import genai
client = genai.Client(api_key="YOUR_API_KEY")
def assert_served(model_id: str) -> None:
"""Fail immediately on an ID that models.list doesn't return."""
served = {m.name.removeprefix("models/") for m in client.models.list()}
if model_id not in served:
near = sorted(s for s in served if s.startswith(model_id.split("-preview")[0]))
raise SystemExit(
"%s is not served. Closest IDs: %s" % (model_id, ", ".join(near) or "none")
)
TARGET = "gemini-3.1-flash-image" # confirmed by following the chain
assert_served(TARGET) # past this line, it's just a constant swapIt prints the nearby IDs so a -preview mixup is visible at a glance. I made that mixup, which is why those two lines exist.
If your model IDs live in one place, the change itself is a constant swap. If they don't, that's the earlier job. And it's worth keeping the two clocks separate: an SDK method going deprecated and a model being retired are different deadlines, which I wrote about in why generate_images and the model retirement don't share a date.
One thing to do before October 2
Just one. Search your code and your notes for gemini-2.5-flash-image, and if anything matches, resolve the target by following the chain rather than copying the row. The end of that chain is gemini-3.1-flash-image.
In date order: gemini-omni-flash-preview on September 30, gemini-2.5-flash-image on October 2. After those two there's a long gap until gemini-3.1-flash-lite on May 7, 2027. I put the September 30 one into a single deadline table in the sunset inventory for gemini-omni-flash-preview.
What I took from this is a small rule I now follow: when I read a replacement, I open that row too. It looks like duplicated work, and it costs far less than discovering in production that I migrated onto something already switched off.