Silent Schema Drift: How a text-to-jsonb Cast Broke Production
Silent Schema Drift: How a text-to-jsonb Cast Broke Production
Silent Schema Drift: How a text-to-jsonb Cast Broke Production
While deploying new features to my portfolio CMS, I hit a classic production database problem. Here's what happened and how to fix it.
The Error
Running a seed script against my production Postgres database, every attempt to initialize Payload CMS failed:
bash
DrizzleQueryError: Failed query: ALTER TABLE "series" ALTER COLUMN "content" SET DATA TYPE jsonb;error: column "content" cannot be cast automatically to type jsonbhint: You might need to specify "USING content::jsonb".
The same error kept repeating for different tables -- series, documents, notebooks_blocks, projects, work_experience -- each time a different column. Whack-a-mole.
What Is jsonb?
Postgres stores JSON in two ways:
json -- stores the raw text string exactly as written. Fast writes, slow reads.
jsonb -- parses the JSON and stores it in a decomposed binary format. Slightly slower writes, faster reads, and supports indexing.
Both hold the same data. For application data, you almost always want jsonb.
text is just a plain string. Postgres has no idea whether it contains JSON -- it's just characters. There's no automatic conversion from text to jsonb because Postgres can't guarantee your strings are valid JSON without checking every row.
What Is Schema Drift?
Schema drift is when your application's schema definition (what the code expects the database to look like) and the actual live database diverge over time.
In this case:
The production database was created when Payload and Drizzle stored Lexical editor fields as text columns.
At some point, the schema definition in code was updated to use jsonb for those fields -- a reasonable improvement.
The production database was never migrated to match.
The application kept working because Postgres is flexible enough to return text values to the application layer, which then parses them.
The drift was silent. The site ran fine, no errors in production logs, nothing to indicate a problem -- until something tried to initialize Payload and push the schema.
Why It Surfaced Now
Payload CMS v3 with the Drizzle adapter has two modes for keeping the DB in sync:
Push mode (pushDevSchema) -- on startup, Drizzle compares its schema definition against the live DB and tries to ALTER any mismatched columns. Default in development.
Migration mode -- you write explicit SQL migration files and run them with payload migrate. Use this in production.
Running the seed script (payload run scripts/seed-resume.ts) called getPayload(), which triggered pushDevSchema. That kicked off the ALTER attempts and is where they failed.
payload migrate had run fine earlier and reported Done. The migration only added new tables. It never touched the existing content columns because I never wrote a migration for them. Drizzle push, on the other hand, noticed all schema drift and tried to fix everything at once.
Why Postgres Refuses the Automatic Cast
When you run:
sql
ALTER TABLE series ALTER COLUMN content SET DATA TYPE jsonb;
Postgres responds: it needs to convert every existing row from text to jsonb, but it can't confirm your text values are valid JSON. If even one row contains plain text, parsing it as JSON would fail. You have to tell it explicitly to attempt the cast.
The fix is the USING clause:
sql
ALTER TABLE series ALTER COLUMN content TYPE jsonb USING content::jsonb;
This casts every value using the ::jsonb operator. If all values are valid JSON, it succeeds. If any aren't, it fails at the specific bad row -- which is useful, because it forces you to confront bad data directly.
Schema-altering operations are not forgiving. Do this first.
Option 1: USING cast
For each affected column, run the ALTER with an explicit cast:
sql
ALTER TABLE "series" ALTER COLUMN "content" TYPE jsonb USING content::jsonb;
Use this when you're confident all existing values are valid JSON.
If a column has a DEFAULT value, drop it first:
sql
ALTER TABLE "documents" ALTER COLUMN "parsed_content" DROP DEFAULT;ALTER TABLE "documents" ALTER COLUMN "parsed_content" TYPE jsonb USING parsed_content::jsonb;
Option 2: Null out the bad rows first
If some rows contain invalid JSON:
sql
UPDATE notebooks_blocksSET content = NULLWHERE content IS NOT NULL AND content NOT LIKE '{%' AND content NOT LIKE '[%';ALTER TABLE notebooks_blocks ALTER COLUMN content TYPE jsonb USING content::jsonb;
Use this when you don't need the non-JSON rows.
Note: The LIKE '{%' filter is a heuristic. A valid JSON string like "hello" starts with ", not { or [. For stricter filtering, use content ~ '^[\[{"]' or reach for jsonb_typeof after a try-cast.
Option 3: Skip the column for now
If rows contain real data you can't afford to lose, leave the column as text and come back when you have a migration plan for the content.
Option 4: Write a proper migration
The right long-term fix is a hand-written migration file:
typescript
// migrations/20260427_fix_jsonb_drift.tsimport type { MigrateUpArgs } from "@payloadcms/db-postgres";import { sql } from "@payloadcms/db-postgres";export async function up({ db }: MigrateUpArgs): Promise<void> { await db.execute(sql.raw(` ALTER TABLE "series" ALTER COLUMN "content" TYPE jsonb USING content::jsonb; ALTER TABLE "projects" ALTER COLUMN "content" TYPE jsonb USING content::jsonb; ALTER TABLE "work_experience" ALTER COLUMN "content" TYPE jsonb USING content::jsonb; `));}
This way the fix lives in version control, is reproducible, and is documented alongside the code that caused the drift.
Disable Push Mode in Production
The root cause of this surfacing at all was Drizzle push running against a production database. Push mode is a development convenience and not safe for prod.
In payload.config.ts, disable it outside development:
In production, schema changes go through payload migrate with explicit, reviewed migration files.
The Lesson
The database and code can diverge for months while the application adapts, types get coerced, and nothing breaks visibly. When something finally tries to reconcile the two -- a push, a migration tool, a new ORM version -- it all surfaces at once.
Signs you might have schema drift:
Your DB was created a long time ago and has never been formally migrated
You've upgraded your ORM or database adapter
You're running push mode on a database that predates the current schema definition
Column types in the DB don't match what your application code declares
How to stay ahead of it:
Use migration files for schema changes, even small ones
Run payload migrate:status to see if anything is pending before you touch anything
Never run push mode against a production database
Take a backup (pg_dump) before any schema-altering operation