Shared Database Schema Migrations: The Cross-Service Gap
Adding a database column in a shared schema requires careful deployment ordering across all consuming services to prevent UndefinedColumnErrors.
Key takeaways
- Migration-before-code ordering is per service, so it gives you nothing when two services share one database.
- Deploy the service that owns a migration before any other service that reads the affected table.
- Make new columns nullable with a default so code deployed before the migration keeps working.
- Catch the does-not-exist error explicitly while a schema change is rolling out, rather than assuming deploy order.
- Expand and contract — add, deploy readers, backfill, then require — is the only ordering that is safe in both directions.
To add a database column when several services share the database, you must ensure that all services querying the table have deployed the new code before the migration that adds the column, or implement a backward-compatible migration strategy.
What went wrong
We experienced an UndefinedColumnError when a new column was added to our AI usage log table. The main bot service, which owned the migration, successfully deployed its release command, adding the new column. However, the marketing service, which also queries this table, had not yet deployed its updated code. When the marketing service's code executed, it attempted to read the newly added column, which it had no knowledge of, causing the system to halt.
Why it happens
Our deployment process correctly orders migrations and code for a single service. The release command runs migrations before new code starts executing for that specific service. This works perfectly when a service is the sole consumer of a table or when all consumers deploy in lockstep. However, when multiple services share a database, this internal ordering guarantee does not extend across service boundaries. A migration executed by Service A can break Service B if Service B's code is not yet aware of the schema change and attempts to access the new column.
What we changed
We adopted a two-pronged approach to mitigate this cross-service dependency:
-
Deployment Ordering: The most straightforward solution is to ensure the service that owns the migration deploys its updated code first. This means the new column is added to the database only after all services that will read it have already deployed code that can handle its absence (e.g., by treating it as nullable or ignoring it).
-
Backward-Compatible Migrations (Expand and Contract): For more complex or critical changes, we employ the "expand and contract" pattern. This involves a multi-step migration process:
- Expand (Add Nullable): First, we add the new column as nullable. This ensures that existing code, which doesn't expect the column, will not error out. We then deploy this change to all services that read the table. This step is crucial: every reader must be updated to tolerate the column's existence, even if it's not yet populated.
- Backfill (Optional but Recommended): If historical data needs to be populated into the new column, this is the stage to do it. This can be a separate, potentially long-running, process.
- Write: Once all readers are updated and any backfilling is complete, we can safely update the services to start writing data to the new column.
- Contract (Make Required/Remove): Finally, after a sufficient period has passed and we are confident that all services have been updated and are actively using the new column, we can make the column non-nullable or, if it was a temporary addition, remove it in a subsequent migration.
We also have a startup routine that dynamically adds missing columns as a safety net. However, this only benefits the service running that routine. It does not solve the fundamental problem of cross-service dependencies where one service's migration can break another service's runtime if not coordinated properly.
How to check your own system
Before deploying any schema migration that adds a column to a shared database table, ask yourself these questions:
- Which services read from this table? Identify every service that has a
SELECTstatement targeting the table in question. - What is the current deployment status of each of those services? Are they all on code versions that can tolerate the absence of the new column? If not, they must be deployed before the migration.
- Can the new column be added as nullable? If yes, this is the preferred first step in a backward-compatible migration.
- Is there a fallback mechanism in place for queries? Consider wrapping queries that access the new column in a
try-catchblock that specifically handlesUndefinedColumnError(or its equivalent in your database driver) until all services have successfully migrated.
This disciplined approach to schema migration in a microservices environment, especially with shared databases, is essential for maintaining production stability and avoiding costly outages.
Disclaimer
This article describes engineering decisions in a trading system. It is not investment advice. Trading involves substantial risk of loss and is not suitable for all investors. Past performance does not guarantee future results. Always do your own research and consider your financial situation before trading.
Frequently asked questions
How do you deploy a database migration across multiple services?
- Split the change so that every intermediate state is valid. Add the column as nullable and deploy it first, then deploy readers, then writers, and only tighten constraints once every service is on the new schema. At no point should a running version of any service depend on a change that has not been applied.
What causes UndefinedColumnError in production but not locally?
- Local environments usually run every migration before any code starts, so the ordering problem is invisible. In production, services deploy independently, and a service can start querying a column whose migration belongs to a different service's release that has not run yet.
Is a startup schema check a substitute for migrations?
- It is a safety net, not a substitute. It only runs in the service that contains it, it cannot express data backfills or constraint changes safely, and it hides drift rather than recording it. Keep it for resilience, but the migration file remains the source of truth.
Related Topics
Tradewink builds explainable market research for self-directed traders. Build a watchlist, inspect signal reasoning and risk context, and paper-track ideas before you decide. Live broker workflows are invite-only when available.
Put this knowledge to work
Tradewink uses AI to scan hundreds of stocks daily and delivers trade ideas with full signal breakdowns — free to start.
Save a signal preview for later
Get a concise AI signal example in your inbox, then build a watchlist when you are ready. No spam, unsubscribe anytime.
Start with free AI trade ideas
See how Tradewink turns market structure, momentum, and risk rules into trade-ready signals. Free to start, with your broker staying in control.
More in Engineering Learnings
Measuring Missed Exit Profits
Quantify exit strategy performance by tracking Maximum Favorable Excursion (MFE) and Maximum Adverse Excursion (MAE) against realized profit and stop.
Read articleDetecting Market Regime Changes
We detect market regime changes using a dual-clock system: a daily Hidden Markov Model for broad market classification and an intraday efficiency ratio…
Read articleSecurely Storing User Broker API Keys
A data-driven approach to securing user broker API keys, detailing a production incident and the implemented safeguards.
Read article