Testing Transactions and DB Migrations

Two kinds of database defect escape ordinary functional testing. The first appears only when things fail halfway or happen at the same time: an order saved without its items, the last item in stock sold twice. The second appears only when the schema changes under real data: a migration that ran in two seconds on the test database locks a production table for twenty minutes, or silently converts values it should have kept.

This page covers both. It explains transactions and isolation levels from a tester’s point of view — accurately, including where PostgreSQL and MySQL differ — shows concurrency tests you can run with two SQL sessions, and gives a method and a checklist for testing schema and data migrations before they reach production.

Transactions and ACID from a tester’s view

A transaction groups several statements so that they take effect all together or not at all. It starts with BEGIN (START TRANSACTION in MySQL is equivalent), ends with COMMIT to keep the changes or ROLLBACK to discard them, and until it commits its changes are invisible to other sessions. The four ACID properties describe what the database promises:

  • Atomicity — all the statements or none: an order, its items, the stock reservation and the payment record are written together, or nothing is written.
  • Consistency — each committed transaction moves the database from one valid state to another: constraints hold and the totals still reconcile.
  • Isolation — concurrent transactions do not see each other’s unfinished work, to the degree the chosen isolation level promises.
  • Durability — once COMMIT returns, the change survives a crash or restart.

Testing atomicity: commit, rollback and failure injection

You do not need to re-test the database engine’s guarantees. What you test is whether the application uses transactions correctly: whether the statements that belong together are grouped in one transaction, whether every error path triggers a rollback, and whether anything outside the transaction — an email, a message to another service, a file — is sent before the data is actually committed.

The method is failure injection: make one step fail after earlier steps have run, then look for leftovers. Engines differ here in a way that matters. In PostgreSQL, an error inside a transaction aborts it, and every later statement fails until ROLLBACK. In MySQL InnoDB, most errors roll back only the failing statement and the transaction stays open — so application code that catches the error and then commits keeps the rows written before it. Test the error paths on the engine you ship on.

A transaction also ends at the database boundary. When an order service commits and then calls a payment service that fails, no database rollback can undo the committed order; the design needs a compensating action (for example, a saga that cancels the order) or an outbox that publishes events only after commit. Test that compensation as a feature in its own right.

  • Invalid data in the last of several items — the order and the earlier items must not remain.
  • A payment provider timeout after the order is written — the order ends in a defined state (pending or cancelled), never “paid” without a payment.
  • The application process killed between two steps (in a test environment) — on restart, nothing is half-written, and retries do not duplicate.
  • A unique-key violation mid-transaction — the user sees a clear error and no partial rows remain.
-- PostgreSQL test database: the order must not survive a failed item
BEGIN;

INSERT INTO orders (order_id, customer_id, status, total_amount, created_at)
VALUES (9001, 317, 'PENDING', 150.00, '2026-09-28 10:00:00');

INSERT INTO order_items (order_item_id, order_id, product_sku, quantity, unit_price)
VALUES (90011, 9001, 'SKU-100', 0, 150.00);  -- violates CHECK (quantity > 0)

ROLLBACK;

SELECT COUNT(*) AS leftover FROM orders WHERE order_id = 9001;  -- expect 0

Concurrency anomalies you can test for

Isolation is a trade-off between correctness and throughput, so databases let transactions see some effects of others. The SQL standard names three phenomena; PostgreSQL’s documentation adds serialization anomalies; and the one testers meet most often in practice is not in the standard’s list at all: the lost update.

  • Dirty read — a transaction reads data written by another transaction that has not committed yet (and may roll back).
  • Non-repeatable read — a transaction reads the same row twice and gets different values, because another transaction committed a change in between.
  • Phantom read — a transaction runs the same query twice and gets a different set of rows, because another transaction inserted or deleted matching rows and committed.
  • Serialization anomaly — the result of committed concurrent transactions matches no order of running them one at a time (write skew is the classic case: two doctors each go off call because each saw the other still on call).
  • Lost update — two transactions read the same value, both compute a new value from it, and the second write silently overwrites the first. The script below reproduces it with two SQL sessions.
-- Setup on a test database: one unit left
CREATE TABLE inventory (
  sku   VARCHAR(40) NOT NULL PRIMARY KEY,
  stock INTEGER     NOT NULL CHECK (stock >= 0)
);
INSERT INTO inventory (sku, stock) VALUES ('SKU-100', 1);

-- Step 1, session A
BEGIN;
SELECT stock FROM inventory WHERE sku = 'SKU-100';   -- reads 1

-- Step 2, session B
BEGIN;
SELECT stock FROM inventory WHERE sku = 'SKU-100';   -- reads 1
UPDATE inventory SET stock = 0 WHERE sku = 'SKU-100'; -- app computed 1 - 1
COMMIT;

-- Step 3, session A
UPDATE inventory SET stock = 0 WHERE sku = 'SKU-100'; -- app computed 1 - 1
COMMIT;

-- Two sales, one unit: B's update is lost.
-- PostgreSQL READ COMMITTED and MySQL InnoDB REPEATABLE READ: both commits succeed.
-- PostgreSQL REPEATABLE READ: step 3 fails with a serialization error; the app must retry.
A lost update: both sessions read stock 1, B sells the unit and commits, then A writes 0 from its stale read; where both commits succeed, two sales take one unit.
  • Session A
  • inventory SKU-100, stock starts at 1
  • Session B
  1. 1 From Session A To inventory: BEGIN, then read the stock SELECT stock
  2. 2 From inventory To Session A: Reads 1 reply
  3. 3 From Session B To inventory: BEGIN, then read the stock SELECT stock
  4. 4 From inventory To Session B: Reads 1 reply
  5. 5 From Session B To inventory: Write 0 and COMMIT The app computed 1 − 1; stock is now 0 UPDATE … SET stock = 0
  6. 6 From Session A To inventory: Write 0 and COMMIT Also computed 1 − 1, from the value A read in step 2, not from the current 0 UPDATE … SET stock = 0

Outcome: PostgreSQL READ COMMITTED and MySQL InnoDB REPEATABLE READ: both commits succeed and B’s update is lost. PostgreSQL REPEATABLE READ: step 6 fails with a serialization error; the app must retry.

Reproduced with two SQL sessions on a test database. Figure steps 1–2, 3–5 and 6 are steps 1, 2 and 3 of the script.

Isolation levels: what each prevents, and how engines differ

The SQL standard defines four levels by which of the three phenomena they must prevent. These are minimum guarantees, and each engine implements them differently, so always check the documentation of the engine and version you run.

PostgreSQL defaults to READ COMMITTED and never allows dirty reads — its READ UNCOMMITTED behaves like READ COMMITTED. Its REPEATABLE READ gives each transaction a snapshot taken at the start of its first statement other than a transaction-control command such as BEGIN, and does not allow phantom reads either, which is stricter than the standard requires; if such a transaction tries to update a row that another transaction changed and committed after its snapshot, it fails with a serialization error that the application must catch and retry. SERIALIZABLE additionally detects serialization anomalies and aborts one of the transactions involved.

MySQL’s InnoDB engine defaults to REPEATABLE READ. Plain SELECTs in a transaction read a consistent snapshot established by the first read; locking reads (SELECT … FOR UPDATE or FOR SHARE), UPDATE and DELETE work on the latest committed rows and take locks, including next-key (gap) locks that block inserts into the scanned range. Unlike PostgreSQL, an update of a row changed by another committed transaction does not fail — it applies to the latest version — so a read-then-write in application code can lose an update unless it uses a locking read or an atomic statement. SQLite implements isolation in its own way, so check its documentation rather than assuming PostgreSQL or MySQL behaviour.

Isolation level    Dirty read   Non-repeatable read   Phantom read
READ UNCOMMITTED   possible     possible              possible
READ COMMITTED     prevented    possible              possible
REPEATABLE READ    prevented    prevented             possible
SERIALIZABLE       prevented    prevented             prevented

Minimum guarantees of the SQL standard. Engines may prevent more:
PostgreSQL never allows dirty reads, and its REPEATABLE READ also prevents phantoms.
Lost update is not in the standard's table; see the engine notes above.

Concurrency tests you can run by hand and automate

The simplest concurrency test needs two SQL sessions and a written schedule of steps, as in the script above. It reproduces the race deterministically, which makes it perfect for a bug report. Then test the real application: send the competing requests in parallel through the API, with a small script or a load tool such as k6 or JMeter using a handful of virtual users, and repeat the run enough times to hit the window.

The expected result of a concurrency test is never “both succeed” when only one may. Exactly one request wins; the other receives a defined response (a conflict, an out-of-stock message, or a transparent retry); and afterwards the data reconciles — stock is never negative, a coupon is not used beyond its limit, and the sum of balances across a transfer is unchanged. Engines detect deadlocks and abort one of the transactions; the application should retry it, and the user should never see a 500.

  • Last item in stock bought by two customers at the same moment.
  • Double-clicked Submit, or a client retry after a timeout — with an idempotency key, the second request returns the first result instead of creating a second order.
  • Two administrators editing the same record — with optimistic locking, the second save is rejected with a conflict instead of silently overwriting.
  • A limited coupon redeemed by many users at once.
  • Transfers in opposite directions between the same two accounts — a classic deadlock pattern.
-- Option 1: lock the row while deciding (PostgreSQL, MySQL; not SQLite)
BEGIN;
SELECT stock FROM inventory WHERE sku = 'SKU-100' FOR UPDATE;
UPDATE inventory SET stock = stock - 1 WHERE sku = 'SKU-100';
COMMIT;

-- Option 2: one conditional, atomic statement; the app checks rows affected = 1
UPDATE inventory
SET stock = stock - 1
WHERE sku = 'SKU-100' AND stock >= 1;

-- Option 3: optimistic locking with a version column; 0 rows affected = conflict
UPDATE orders
SET status = 'SHIPPED', version = version + 1
WHERE order_id = 1042 AND version = 7;

Schema and data migrations: what goes wrong

A schema migration changes structure — adds a table, a column, an index or a constraint, renames or drops something. A data migration changes content — backfills a new column, splits a name field, converts currencies. Migration tools such as Flyway, Liquibase or a framework’s own migrations apply numbered scripts in order and record which ran. The risks are the same whichever tool you use:

  • Locks and table rewrites — some operations, such as changing a column’s type or building an index without the engine’s online option, lock or rewrite the table. Seconds on a test database can become minutes or hours at production volume. How a given operation behaves depends on the engine and its version.
  • Data loss — dropping or narrowing a column, reducing decimal scale, converting text to numbers, or changing the character set can destroy or corrupt values without an error.
  • Incomplete backfills — the script misses NULLs, unusual values, or rows created while it was running.
  • Compatibility during rollout — in a rolling deployment the old and new application versions run against the same schema at the same time, so renaming a column breaks the old version immediately.
  • Partial failure — MySQL commits implicitly before and after DDL statements, so a multi-step migration that fails halfway leaves the schema half-changed. PostgreSQL can run most DDL inside a transaction, but some statements, such as CREATE INDEX CONCURRENTLY, cannot run inside a transaction block.
  • One-way steps — a down script can recreate a dropped column but not the data that was in it.

How to test a migration

Rehearse every significant migration on a database with production-like volume and shape — the same engine and version, similar row counts, similar distributions of values. Build it from generated synthetic data, or from production data only through an anonymisation process your data protection officer has approved; Saudi Arabia’s PDPL applies to personal data in test environments too. Measure how long the migration takes and watch for blocked queries while it runs, ideally with test traffic hitting the application at the same time.

Verify with queries you run before and after: row counts, sums of money columns, distributions by status, and checks specific to the change — no NULLs left in a backfilled column, only allowed values, constraints valid. Save the before results so the comparison is exact.

Plan compatibility with the expand-and-contract pattern (also called parallel change): first add the new structure while keeping the old one, write to both, backfill, switch reads to the new structure, and remove the old one only in a later release. Every step is then backward-compatible, and you can test the old application version against the new schema. Finally, test the way back: run the down migration or the restore procedure on the rehearsal database, time it, and write down which steps are one-way.

-- Fingerprint: run before and after the migration and compare the results
SELECT 'orders' AS table_name, COUNT(*) AS row_count, SUM(total_amount) AS amount_sum FROM orders
UNION ALL
SELECT 'order_items', COUNT(*), SUM(quantity * unit_price) FROM order_items
UNION ALL
SELECT 'payments', COUNT(*), SUM(amount) FROM payments;

-- A distribution that must not change
SELECT status, COUNT(*) AS order_count
FROM orders
GROUP BY status
ORDER BY status;

-- Example: after adding orders.currency with a backfill to 'SAR'
SELECT COUNT(*) AS missing_currency FROM orders WHERE currency IS NULL;

SELECT currency, COUNT(*) AS order_count
FROM orders
GROUP BY currency
ORDER BY currency;

Try it: SQL Dataset Generator Try it: SQL Query Validator

Migration test checklist

Copy this checklist into the ticket of every migration that changes data or touches a large table. It is deliberately short; each line is a question someone must be able to answer yes to before the release.

MIGRATION TEST CHECKLIST: <migration id>

Before
[ ] Change classified: schema, data, or both
[ ] Destructive steps identified (DROP, narrower type or length, data rewrite)
[ ] Rehearsal database at production-like volume (synthetic or approved anonymised data)
[ ] Fingerprint queries run and results saved (counts, sums, distributions)
[ ] Backup or point-in-time restore confirmed; restore time known

During the rehearsal
[ ] Duration measured; locks and blocked queries observed
[ ] Application traffic running while the migration runs
[ ] Failure halfway simulated; resulting state understood

After
[ ] Fingerprint queries match, or differ exactly as intended
[ ] New columns populated; no unexpected NULLs; constraints valid
[ ] Old application version works against the new schema
[ ] New application version passes smoke and integrity checks
[ ] Down migration or restore tested; one-way steps documented
[ ] Plans of the main queries unchanged or better

Automating transaction and migration tests in CI

Run database tests on the same engine and major version as production, started fresh in the pipeline — typically in a container. Substituting SQLite or an in-memory database for PostgreSQL or MySQL hides exactly the behaviour this page is about: isolation, locking, DDL in transactions and constraint enforcement all differ.

The pipeline should apply all migrations to an empty database, apply the new migrations to a database seeded at the previously released version, run the integration tests including the concurrency cases, and finish with the read-only integrity queries. Large-volume rehearsals take longer and usually run on a schedule or before release rather than on every commit.

  • Migrations from scratch and from the previous release both succeed.
  • Down migrations, where the team supports them, run cleanly on seeded data.
  • Concurrency tests assert on the final data, not only on response codes.
  • Integrity queries (orphans, duplicates, reconciliation) return zero rows after the suite.
  • A migration that locks a table beyond the agreed limit in rehearsal blocks the release.

Try it: SQL INSERT Generator Try it: Boundary Number Dataset Generator

Frequently asked questions

Which isolation level should our application use?

Usually the engine default, plus explicit protection where a business rule depends on reading and then writing the same data: a locking read, an atomic conditional update or optimistic locking. Stricter levels such as SERIALIZABLE are correct but require the application to retry aborted transactions. It is a design decision; testing shows whether the chosen approach holds.

Does PostgreSQL’s REPEATABLE READ allow phantom reads?

No. The SQL standard permits phantoms at REPEATABLE READ, but PostgreSQL’s implementation uses a snapshot that prevents them. It still allows serialization anomalies, which only SERIALIZABLE prevents.

Can every migration be rolled back?

No. Adding structure is usually reversible; dropping columns, narrowing types and rewriting data are not, because the original values are gone. For those steps the rollback plan is a backup or point-in-time restore, tested and timed in the rehearsal.

How much data do we need for a migration rehearsal?

Enough to reproduce production behaviour: similar row counts for the tables the migration touches and realistic distributions of values, including NULLs and edge cases. Ten rows prove only that the script parses.

How do we test concurrency without special tools?

Open two SQL sessions and follow a written step schedule to reproduce the race exactly. For the application, send parallel requests with a small script or a load tool at low user counts, repeat the run, and assert on the final data.

Is SQLite good enough for database tests in CI?

For logic that does not depend on the engine, it can be. For transactions, isolation, locking, migrations and constraint enforcement, test on the engine you ship — SQLite behaves differently from PostgreSQL and MySQL in each of these areas.

References

Practice exercise

Review a gift-card checkout and its migration

Your task

You are testing release 5.3 of a grocery delivery app (fictional), which lets customers pay for an order with a gift card. One pull request brings two changes, shown in the material below: A, the new checkout handler as pseudo-code, and B, migration V42, which changes the gift_cards table before the first 5.3 instance starts. Production runs MySQL 8.0 with InnoDB at its default isolation level, and the release goes out as a rolling deployment.

Work on paper with the guide: failure injection, the two-session schedule, the migration risks, the fingerprint queries and the migration test checklist. A database is optional; if you have a MySQL 8.0 test instance, you can run your schedule from step 2 on it. Write each finding the way you would raise it in the review: the line or statement, what goes wrong on this engine, the test that shows it and the expected result. A decimal dataset generator helps with rehearsal data for the balance change, and a text diff compares saved before-and-after query results. Allow 45–60 minutes.

Material
Grocery delivery app (fictional) - release 5.3
Gift cards already exist: customers buy them, check the balance and, since
release 4.8, receive cashback on them with 3 decimals (for example 12.345).
Release 5.3 adds paying for an order with a gift card. All gift cards are in SAR.
Production: MySQL 8.0, InnoDB, default isolation level (REPEATABLE READ),
about 2 million gift cards. Test database: MySQL 8.0, 300 gift cards.
Rolling deployment: four app instances are replaced one at a time, so
releases 5.2 and 5.3 serve traffic side by side for about 15 minutes.

A. Checkout handler, release 5.3 (pseudo-code; cart.total = sum of all cart items)

 1  placeOrder(cart, cardCode):
 2    BEGIN
 3    card = SELECT balance FROM gift_cards WHERE card_code = cardCode
 4    if card.balance < cart.total:
 5      ROLLBACK; return 409 "Insufficient gift card balance"
 6    orderId = INSERT INTO orders (customer_id, status, total_amount)
 7              VALUES (cart.customerId, 'PAID', cart.total)
 8    for each item in cart.items:
 9      try:
10        INSERT INTO order_items (order_id, product_sku, quantity, unit_price)
11        VALUES (orderId, item.sku, item.qty, item.price)  -- FK to products(sku)
12      catch DatabaseError:
13        log.warn("item skipped", item.sku)
14    newBalance = card.balance - cart.total
15    UPDATE gift_cards SET balance = newBalance WHERE card_code = cardCode
16    sendEmail(cart.customerEmail, "Order confirmed", orderId)
17    COMMIT
18    return 201 { orderId }

B. Migration V42 - applied once, before the first 5.3 instance starts.
   On the test database it ran in 3 seconds.

ALTER TABLE gift_cards ADD COLUMN currency CHAR(3) NULL;
UPDATE gift_cards SET currency = 'SAR' WHERE currency = '';
ALTER TABLE gift_cards MODIFY balance DECIMAL(10,2) NOT NULL; -- was DECIMAL(12,3)
ALTER TABLE gift_cards RENAME COLUMN code TO card_code;
Steps
  1. Part A, atomicity: find the two places where the handler can leave data or side effects that do not match what was committed. For each, write a failure-injection test: what you make fail, what you check afterwards, and the expected result.
  2. Part A, concurrency: find the race in the handler. Write a two-session schedule that reproduces it on the test database with card GC-T-001 (balance 100.00) and two orders of 60.00 and 70.00, with the result the current code produces and the correct result. Then describe the parallel API test and what it asserts.
  3. Part B: review V42 statement by statement against the guide’s migration risks: locks and table rewrites, data loss, incomplete backfills, compatibility during the rolling deployment, partial failure and one-way steps.
  4. Write the rehearsal plan for V42: the rehearsal database, the fingerprint queries you run before and after with the results a correct migration must give, and what you observe while it runs.
  5. Decide whether V42 and the handler can ship as written. List what must change and the questions you would ask the developers and the product owner.
Show the model answer
Model answer

Atomicity, lines 8–13: the swallowed item error. On MySQL InnoDB, an error such as a foreign-key violation rolls back only the failing INSERT; the transaction stays open, so the COMMIT on line 17 keeps the order, the items written before the error and the reduced balance. The customer pays the whole cart.total, including an item that was never saved. Failure-injection test: put three items in the cart, the third a test product, and remove that product from products before checkout, as if it had been taken out of the catalogue (or send the third item with a SKU that does not exist); then check out with a card that has enough balance. Expected: a clear 4xx message such as "an item in your cart is no longer available", no new row in orders or order_items for the test customer, the card balance unchanged and no email. This code returns 201 and leaves an order with two items and the full total charged. Fix to retest: any database error rolls back the whole transaction and returns that message. On PostgreSQL the same code fails differently: the error aborts the transaction and every later statement fails, so nothing is committed, but the request ends in an unhandled error. That is why the guide says to test error paths on the engine you ship.

Atomicity, lines 16–17: the email before COMMIT. The confirmation leaves the system before the data is committed, and no rollback can call it back; it also keeps the gift card row locked while the mail server responds. Test, in the test environment: pause the application on a breakpoint between lines 16 and 17, after the email is sent and before COMMIT, and kill the process there, or use a fault-injection switch the developers add for tests. Expected: nothing half-written, and no confirmation for an order that is not in the database. This code sends "Order confirmed" with an order id that does not exist, because MySQL rolls back an uncommitted transaction when its connection closes. Fix to retest: send the email after COMMIT, or write it to an outbox table inside the transaction and send it only after the commit.

Concurrency, lines 3, 14 and 15: read, then write a value computed in the application. Line 3 is a plain SELECT, so two checkouts with the same card can both read the old balance, both pass the check on line 4, and each writes a balance computed from that old value: a lost update. On MySQL InnoDB at REPEATABLE READ the second UPDATE does not fail; it applies to the latest version of the row, so both commits succeed and the card pays for more than it holds. The guide’s fixes all work here: a locking read (SELECT balance ... FOR UPDATE on line 3); one atomic conditional statement, UPDATE gift_cards SET balance = balance - ? WHERE card_code = ? AND balance >= ?, after which the code checks that exactly one row was affected and otherwise rolls back and returns 409; or optimistic locking with a version column. The developers choose; your tests must pass for whichever they choose.

Two-session schedule on a MySQL 8.0 test database, with GC-T-001 at 100.00. Step 1, session A: BEGIN; then SELECT balance FROM gift_cards WHERE card_code = 'GC-T-001'; reads 100.00. Step 2, session B: BEGIN; and the same SELECT also reads 100.00. Step 3, session B, the 60.00 order: UPDATE gift_cards SET balance = 40.00 WHERE card_code = 'GC-T-001'; then COMMIT;. Step 4, session A, the 70.00 order: UPDATE gift_cards SET balance = 30.00 WHERE card_code = 'GC-T-001'; then COMMIT;. Current result: both commits succeed and the balance is 30.00, although 130.00 of orders were paid from 100.00; B’s update is lost. On PostgreSQL at REPEATABLE READ, step 4 would fail with a serialization error instead, and at its default READ COMMITTED the update is lost there too. Correct result: only one order is paid. With FOR UPDATE on line 3, B’s read in step 2 waits; A goes on to pay 70.00 and commit, then B reads 30.00 and must refuse the 60.00 order with 409.

Then the application test: send the two checkouts for GC-T-001 in parallel through the API, with a small script or a load tool such as k6 or JMeter and two virtual users. Reset the card to 100.00 and repeat the run many times, because the race window is short. Assert on the final data, not only on status codes: in every run exactly one 201 and one 409 "Insufficient gift card balance"; the final balance is 40.00 or 30.00 and matches the order that won; it is never negative; and the totals of the paid orders never add up to more than 100.00.

Migration V42, statements 1 and 2. Adding a nullable column changes structure only and is reversible. The backfill is wrong: after statement 1 every existing row has NULL in currency, and NULL = '' is never true, so WHERE currency = '' updates no rows; on the test database the UPDATE reports 0 affected rows instead of 300. The condition must be WHERE currency IS NULL; on 2 million rows that UPDATE also locks every row it scans until it commits, so time it in the rehearsal and run it in batches. Even then, the 5.2 instances that run during the rollout keep creating gift cards without a currency. Either the column gets DEFAULT 'SAR', so that every new card gets SAR whichever version writes it, or 5.3 writes SAR and the backfill runs again after the last 5.2 instance is gone. Only then can a later migration make the column NOT NULL.

Statement 3, balance from DECIMAL(12,3) to DECIMAL(10,2), is a data migration hidden in a schema change. Data loss: every balance with a third decimal, such as 12.345 from cashback, loses that digit without an error, so customers’ money changes; and it is a one-way step, because a down migration cannot bring the lost digit back, so the way back is a restore from backup. Precision also falls from 12 to 10 digits, so check that the largest balance still fits. Locks: changing a column’s type makes MySQL 8.0 copy the whole table, and writes to gift_cards wait until the copy finishes; 3 seconds for 300 rows says nothing about 2 million. Statement 4, the rename: V42 runs while all four instances are still 5.2, so from that moment until the last 5.2 instance is replaced, every 5.2 query that still uses code fails, for example showing a balance or crediting cashback. Partial failure: MySQL commits implicitly before and after each DDL statement, so if V42 stops at statement 3 or 4, the earlier statements stay applied, and running V42 again fails at once on statement 1 because currency already exists.

Rehearsal plan. Database: MySQL 8.0 like production, with about 2 million gift cards of generated synthetic data shaped like production (balances with two and three decimals, zero balances, the largest realistic values), or production data only through an anonymisation process the data protection officer has approved, because gift cards belong to customers and PDPL applies to personal data in test environments too. Before: confirm the backup or point-in-time restore, then run and save SELECT COUNT(*) AS card_count, SUM(balance) AS balance_sum FROM gift_cards; and SELECT COUNT(*) AS three_decimals FROM gift_cards WHERE balance <> ROUND(balance, 2);. During: keep 5.2 test traffic running, because 5.2 is the version live while V42 runs, measure the duration of each statement, watch for blocked queries, and in one run stop the migration halfway and record the state it leaves. After: card_count and balance_sum are unchanged (if three_decimals was above 0, V42 as written changes that many balances, and balance_sum will usually differ too: the data loss in numbers); SELECT COUNT(*) AS missing_currency FROM gift_cards WHERE currency IS NULL; returns 0 and grouping by currency shows only SAR; 5.2 still works against the new schema; 5.3 passes the smoke and integrity checks; the restore has been tested and timed. Compare the saved results with a text diff, not by eye.

Decision: neither change ships as written. Blocking: the partial order and the lost update (customers pay for goods they do not get, or a card pays for more than it holds), the email before COMMIT, the backfill that updates nothing, the rename that breaks 5.2 during the rollout, and a change to customers’ balances that nobody has decided. Ask for an expand-and-contract plan: V42 only adds currency; 5.3 writes SAR for new cards, or the column gets that default; the batched backfill WHERE currency IS NULL runs after the rollout and NOT NULL comes in a later release; the 5.3 handler keeps the name code, or card_code is added, written alongside code and backfilled, reads switch to it, and code is dropped only in a release after 5.3; the balance change waits for the product owner’s decision and gets its own migration and rehearsal; one DDL change per migration, so that a failure leaves a known state. Questions for the product owner: what happens to the third decimal of existing balances (which rounding rule, a report of the differences, are customers told)? For the developers: which concurrency fix, and does the losing request always end in the 409, never in a 500 or a lock-wait timeout? Is there an agreed lock-time limit that blocks the release?

If your list differs, check each finding against the guide’s questions. Does the handler group everything that belongs together in one transaction, roll back on every error and do nothing outside the database before COMMIT? Does the concurrency test assert on the final data? Does every migration step stay compatible with the version still running, keep the data and have a tested way back? A finding the guide does not name is fine if you can state the failure it causes and the test that shows it.

Your progress on this topic

Part of the QA Roadmap

Tools mentioned in this guide

Generate SQL seed scripts (CREATE TABLE + INSERTs) with realistic test rows for any dialect.

Test Data

Check SQL statements for syntax problems, unbalanced parentheses, unterminated strings and risky patterns.

Database

Format SQL queries with consistent keyword casing, indentation and line breaks (MySQL, PostgreSQL, SQL Server, Oracle, SQLite).

Database

Generate INSERT statements from a table definition, sample rows, CSV or JSON.

Database

How Mutqan Soft can help

More guides

More guides