DEPLOY.LEGACY.ORPHAN_TRANSITION_OBJECT — An object whose name says it was meant to be temporary
- Category: safety
- Severity:
low— housekeeping that has waited months already, not something a deploy is blocked on. Reporting it beside findings that stop a release would teach a reader to discount both - Level: 0
- Downtime class:
online— the object exists and does nothing, which is the entire complaint. Nothing here holds a lock or rewrites anything - Stability: stable
- Suites: deploy
- Command:
sqlens:postdeployonly - Applies to: PostgreSQL 18 and newer
What it reads
Tables, partitioned tables, views, materialized views and sequences whose bare name matches a transition-leftover shape:
users_old a rename that was never completed
tmp_backfill_state a helper the backfill needed and nobody removed
orders_20260721 a snapshot taken "just in case"
accounts_bak the same, under another convention
legacy_counter_tmp a sequence left behind with its table
Each survives indefinitely, costs storage and backup time forever, and — the part that actually bites — is the first thing somebody restores by mistake, because it looks like the real table with a suffix.
It NEVER fails, and that is the design
A name is not evidence. orders_old is exactly what an abandoned rename leaves behind and
exactly what a team calls the archive it queries every quarter. The catalog holds nothing that
separates the two.
So every match is reported as undetermined, with the reason
name_suggests_transition_object, and never as a failure. The alternative was weighed against what
it would cost: a heuristic that presents itself as certainty gets one false positive on a table
people rely on, and that is the last time anybody reads the report. pass would be worse — it
would hide real wreckage behind a clean result.
The second reading that settles it — --expect-shadow
What turns this into a decided answer is a second, independent reading: the object appearing in no migration state at all. That is a different check, deliberately — this one owns the catalog reading and the name heuristic, and nothing else.
Ask for it and the two readings meet:
php artisan sqlens:postdeploy --expect-shadow
The option replays your migrations into a throwaway shadow database and compares the result against
what the live database actually holds. When that comparison reports the same object as
unexpected_in_database, the name says leftover and the migrations say nothing describes it — two
readings from different directions, agreeing — and the finding is raised from undetermined to
fail.
Three things about that promotion are worth knowing before you rely on it:
- It never demotes, and it never fires on silence. A comparison that could not run, or one that found the object perfectly accounted for, leaves the finding exactly as it was. The name still looks like a leftover, and that did not stop being true because a second reading disagreed.
- The option is off by default, and that is structural. Proving the match creates a database, so the run falls under the production guard — while the plain catalog aftercare stays lightweight and guard-free, cheap enough to run on every deploy.
- A run without it says so out loud, on stderr and in the report's
run.expectationblock. A post-deploy that did not compare has proved less than a green tick suggests, and "your schema matches" and "I did not look" must never read the same.
Three exclusions, none of them this check's invention
- Extension-owned objects (
pg_depend). A project cannot act on what an extension owns, and reporting one is asking somebody to fix what they do not control. - Partition children (
pg_inherits). A partitioned table's children carry the parent's name plus a suffix, so every one of them matches whatever the parent matched. A hundred identical findings for one table is the shape that teaches a reader to skim. - System schemas.
pg_catalog,information_schema, the TOAST and temp schemas — none of them is anybody's migration.
Indexes are absent on purpose: an orphaned index is what DEPLOY.LEGACY.INVALID_INDEX answers, and
two checks reporting one object is the same skim problem in another form.
Configuring the shapes
// config/sqlens.php
'deploy' => [
'postdeploy' => [
'transition_patterns' => null, // null = the shipped list
],
],
The shipped list, as PCRE against the bare name: /_old$/, /_new$/, /_tmp$/, /_temp$/,
/_bak$/, /_backup$/, /_copy$/, /_migration$/, /_\d{8}$/ and /^tmp_/.
Every one is anchored, which is what keeps it usable: /_old$/ matches users_old and not
threshold_settings. What is deliberately not in the list is _v2, _copy_of, _a/_b and
anything shorter than three characters — each appears in real permanent schemas often enough that
including it would spend the check's credibility to catch a case the dated suffix already covers.
A configured list replaces the shipped one rather than adding to it, so a project with its own convention says what that is instead of inheriting ours alongside it.
Two safeguards on the value, and both err toward speaking rather than silence:
- a pattern PCRE cannot compile is dropped, not obeyed;
- a list that ends up empty falls back to the shipped one, because an empty list would silence the check completely and let a project read "no findings" as "nothing left behind".
Primum non nocere
The remedy is a DROP, and a drop is irreversible. This check reports it as a proposal and
never runs one — the message says so in its own words rather than leaving it to be assumed.
If the object is one you meant to keep, the honest answer is not to suppress the check but to say
so once: narrow transition_patterns to the shapes your project actually uses.
Sources
- PostgreSQL 18 — System catalogs —
pg_class,pg_dependandpg_inherits, which is what the three exclusions read