
TL;DR
A war-story walkthrough of moving a 5-table app from Convex to Neon with Drizzle, one PR at a time, with the trade-offs we hit along the way.
We just finished moving one of our internal apps, dd-clipper, from Convex to Neon with Drizzle. Five tables, five pull requests, one planning doc. This post is the honest version of how it went, what we gave up, and what we would tell a developer staring down the same migration.
This is not a "Convex bad, Postgres good" post. Convex is a genuinely well-designed product, and a lot of what felt easy on Convex turned out to be load-bearing once it was gone. We moved for our own reasons. The point of writing this up is so the next team knows what they are signing up for.
The trigger was not a bug or an outage. It was a slow accumulation of small frictions.
We wanted plain Postgres semantics. Most of our other apps already run on Neon with Drizzle, so every dd-clipper feature carried a small mental tax: which model is in Convex, which is in Postgres, which auth context applies, which client. Sharing helpers across apps was awkward. Running ad hoc analytics queries meant either exporting data or learning more of the Convex query language than we wanted to.
We also wanted commodity tooling. SQL clients, migration files we can read in a diff, EXPLAIN, the ability to drop into psql at 2am, the ability to point any ORM or BI tool at the database without an adapter. Convex gives you a lot in exchange for not having those things. We decided we wanted them back.
That is the trade. Convex hands you reactivity, server functions, a scheduler, file storage, and a single-writer mutation model that makes a whole class of race conditions impossible. Postgres hands you SQL, a giant ecosystem, and the responsibility to wire all of that yourself.
Before writing any migration code we wrote a plan as PR #5. That document listed every Convex table, every consumer of every table, the Postgres schema we wanted, and the order of operations. The order matters, because each table has a different blast radius if you get it wrong.
We landed it in this sequence:
apiKeys. The smallest table. Only used by API auth middleware. A safe place to validate the migration shape, the Drizzle schema layout, and the rollout pattern.apiCredits. Per-user credit balances. This is where the first real lesson hit us, covered below.apiUsageLog. Append-only usage records. Easy in shape, but a high-write path.clips. The biggest table by row count, by query complexity, and by surface area. Saved clips, listing, search, filters, sort, pagination, detail pages. Also the place where we lost real-time subscriptions.usageEvents. Analytics-style events. Append-only again, easier than clips.Every PR followed the same script. Add the Drizzle schema. Add a Postgres-backed module under db/. Replace call sites. Mark the corresponding convex/*.ts file as deprecated but leave it in the repo for one release so the generated client types do not break for the SDK and CLI. Ship behind a feature switch where possible, then collapse.
That last part is unglamorous and important. We did not delete Convex on the way out. We let the deprecated files sit there long enough to confirm nothing downstream still referenced them, then removed in a follow-up. Migrations that try to be clean in a single PR are the ones that take down the SDK consumers you forgot about.
Get the weekly deep dive
Tutorials on Claude Code, AI agents, and dev tools - delivered free every week.
In Convex, deducting a credit looks like a normal mutation. You read the balance, check it, decrement it, write it back. Convex serializes mutations per document, so two requests racing for the same user cannot both pass the check. You get correctness for free.
In Postgres, that exact same code is a textbook race condition. Two requests can both read a balance of 1, both pass the check, both decrement, and you end up with negative credits and an angry customer.
The fix is one statement, not a transaction full of reads and writes:
UPDATE api_credits
SET balance = balance - 1
WHERE user_id = $1 AND balance >= 1
RETURNING balance;
If the row comes back, you charged them. If nothing comes back, they did not have enough credit. No SELECT-then-UPDATE, no app-level lock, no SERIALIZABLE retry loop. Drizzle exposes .returning() on update, and that is the version that shipped in PR #7.
The lesson is more general than this one query. Anywhere your old Convex code relied on the single-writer model for safety, you need to find the equivalent in SQL. Sometimes that is UPDATE ... RETURNING. Sometimes it is INSERT ... ON CONFLICT. Sometimes it is SELECT ... FOR UPDATE inside a transaction. The work is not hard. The work is finding every place you implicitly depended on the guarantee.
This was the lesson of PR #9, the clips table. In Convex, useQuery is reactive by default. A new clip lands, every open tab updates. We were not using that as a feature so much as we were leaning on it as background behavior. The clip library "just stayed fresh."
Postgres does not do that. Drizzle does not do that. You can poll, you can use LISTEN/NOTIFY, you can put a Pusher or Ably or Supabase Realtime layer in front of it, you can move to server-sent events. None of those are a one-line replacement for useQuery.
We picked the boring option for now: short-interval revalidation on the pages that need it, plus optimistic updates on the actions a user takes locally. That covers most of what reactivity gave us, at the cost of a little staleness when another device or another user changes state. We did not try to rebuild full reactivity in the first pass. If you migrate, plan for this explicitly. Either accept the staleness, or pick your pubsub layer up front, because retrofitting it after the rest of the migration is a second project.
The five tables migrated. The clip blobs did not. They still live in the Convex _storage bucket, fronted by generateUploadUrl and getFileUrl in the deprecated convex/clips.ts. We made a deliberate call: a table migration and a blob migration are different shapes of work, and combining them turns one well-scoped project into a worse-scoped one.
This is the part to flag for anyone planning a similar move. If your Convex app uses _storage, your migration is at least two projects. Treat the file storage move as its own design with its own destination, S3 or R2 or a Neon-compatible blob store, and its own cutover. Do not let it block the table migration, but do not pretend it does not exist either.
Three things, in priority order.
Write the planning doc earlier and longer. PR #5 saved us. The version we shipped was about half the length it should have been, and we paid for the missing half in surprises during PR #9.
Stub the reactivity story before PR #9, not during it. Even a "we accept 5 second staleness, here is the polling helper" decision in writing would have removed a day of yak-shaving in the clips PR.
Decide on the file storage destination on day one. We still have not picked, and that is fine, but the longer the deprecated convex/clips.ts sits in the repo as a bridge, the more it shows up in code review as a question we have to answer again.
Probably not, if your app is mostly fine on Convex. The reactive model is a real product feature. The single-writer mutation guarantees are a real correctness feature. The integrated file storage is a real time saver. Tearing those out has a cost, and the only honest reason to take that cost is that you want what Postgres gives you on the other side.
The migration makes sense if you already run Postgres elsewhere and the split-stack tax is high. It makes sense if you want to share schema and helpers across apps. It makes sense if you want to point the rest of your tooling, BI, analytics, batch jobs, at the same database your app uses. It makes sense if you have outgrown the Convex query model and keep wishing you could write SQL.
If you are building something new and any of the above describe you, just start on Neon with Drizzle. If you are on Convex and shipping fine, the migration is a project, not a free win.
For more on how the rest of our stack fits together, see the DD apps overview, the tools comparison page, and the build write-ups for Promptlock and Hookyard where we use the same Neon and Drizzle pattern from the start.
Technical content at the intersection of AI and development. Building with AI agents, Claude Code, and modern dev tools - then showing you exactly how it works.
Type-safe SQL builder and ORM for TypeScript. Zero runtime overhead, honest schema migrations, bring-your-own-DB.
View ToolReactive backend - database, server functions, real-time sync, cron jobs, file storage. All TypeScript. This site's ba...
View Tool
We ran the same Convex to Neon migration on four apps in a week. Here is what stayed identical, what differed per app, a...

agentfs is filesystem-shaped storage for AI agents. Postgres-backed on Neon, no cold starts, no exec by design. Pay-only...

Convex and Supabase both work for AI-powered apps. Here is when to use each, based on building production apps with both...

New tutorials, open-source projects, and deep dives on coding agents - delivered weekly.