Guide ยท 9 min read

Moving from a balances column to a double-entry ledger.

Nobody gets a maintenance window for this. The money keeps moving while you change the thing that records it, which is why the answer is never a big-bang migration. Dual-write, backfill behind a cutoff, compare in production, then flip the read.
one number, or a list of movementsthe whole change

what you have

accounts
  id            balance_cents
  acc_8821            240000

UPDATE accounts
   SET balance_cents = balance_cents - 24000
 WHERE id = 'acc_8821';

what you want

postings
  txn        account            amount
  txn_41   acc_8821            -24000
  txn_41   acc_merchant_pay    +24000

balance = SUM(amount) WHERE account = ?

The balance stops being a number you maintain and becomes a number you derive. That is the whole change.

The actual problem

An UPDATE cannot tell you what happened.

A stored balance answers one question, badly: what is it now. It cannot answer what it was last March, why it changed, who changed it, or whether the change was balanced by anything. Every one of those questions arrives eventually, usually from someone who is not going to accept "we would have to check the logs".

A ledger answers all four by construction, because the balance is derived from an append-only list of movements rather than overwritten in place. The migration below is the safe path between those two worlds.

The migration

Five phases, none of them a big bang.

Each one is reversible on its own, and none of them requires the money to stop moving.
  1. 01

    Stop the bleeding before you move anything

    Freeze new writers. Every place that touches the balance column gets funnelled through one function, today, before any migration work starts. If you cannot name every writer, the migration will not survive contact with the one you forgot.

  2. 02

    Write to both, read from the old one

    The new ledger takes every movement in parallel. Nothing reads it yet, so a bug here costs you nothing. This is the phase where you discover which of your movements were never really balanced.

  3. 03

    Backfill history behind a cutoff

    Pick a timestamp. Everything after it is already dual-written. Everything before becomes one opening balance per account, not a replay of five years of rows you no longer trust.

  4. 04

    Compare continuously, in production

    A job that reads both and reports drift, running for at least a full billing cycle. Month end is where the disagreements show up, because that is when the edge cases run.

  5. 05

    Flip the read, keep the old column

    Reads move to the ledger. The old column keeps being written for one more cycle so you can roll back without a data recovery exercise. Then you drop it, deliberately, on a day nothing else ships.

Phase two, in code

Write both, trust one.

The ledger write goes inside the same transaction boundary as the old one where your stack allows it, and is idempotent either way so a retry cannot double-post. Nothing reads the new numbers yet.
dual writeold is still authoritative
async function debitCustomer(order, amountCents) {
  await db.transaction(async (tx) => {
    await tx.decrementBalance(order.customerId, amountCents);

    await kordio.transactions.create({
      idempotencyKey: `order:${order.id}:debit`,
      postings: [
        { accountId: acc(order.customerId), amount: -amountCents, currency: 'EUR' },
        { accountId: acc('merchant_payable'), amount:  amountCents, currency: 'EUR' },
      ],
      metadata: { orderId: order.id },
    });
  });
}

What comparison finds

Five disagreements, in order of how often they show up.

These are not exotic. Every team that runs a comparison job finds at least two of them, and finding them is the reason to run it.
What driftsWhy it was invisible before
A movement with no counterpartyA fee taken out of a balance with nothing credited. It nets in a stored column and is rejected by a ledger, which is the point.
Two writers, one balanceA refund path and a payout path that both decrement, discovered only because the ledger makes the second one fail.
Rounding taken once per rowFractions dropped per movement instead of carried. Invisible at ten rows, a real number at a million.
A currency assumptionEverything stored in one currency because there was only one, then a second one arrives and the column has no idea.
Manual adjustmentsThe support tooling that writes the balance directly. Always exists, never in the diagram.

Phase four

The comparison job is the migration.

Everything else is plumbing. This is the part that tells you whether you can cut over, and it should run on a schedule and report a number rather than being something a person remembers to do.

Deciding whether to do any of this at all is a fair question, and it has a real answer:build versus buy. The idempotency keys above are load-bearing, and they have their own guide:how idempotency actually works.

drift checkruns hourly
for (const account of await activeAccounts()) {
  const stored  = await db.balanceOf(account.id);
  const derived = await kordio.balances.get(acc(account.id));

  if (stored !== derived.amount) {
    report({
      account: account.id,
      stored,
      derived: derived.amount,
      delta: stored - derived.amount,
    });
  }
}

Cut over when this reports zero across a full close cycle. Not when it reports zero once.

Questions

What teams ask halfway through.

Migrating something large, or want a second pair of eyes on the cutover plan?sales@kordio.io.
Do we have to migrate historical data at all?+

Usually not. One opening balance per account as of a cutoff is enough for correctness going forward, and it is far less risky than replaying years of rows whose semantics you no longer remember. Keep the old table read-only for history.

How long should the dual-write phase run?+

At least one full close cycle, so month-end processes run against it once. Teams that shorten this are the ones that find a discrepancy after cutover instead of before.

What if the two disagree during comparison?+

That is the migration working. A disagreement is a bug in the old path roughly as often as in the new one, and finding it while the old column is still authoritative is the cheapest time to find it.

Can we skip the balance column entirely on a new product?+

Yes, and that is the easiest way to adopt a ledger. Land the new product, the new rail or the new market on it, leave the running system alone, and let the old balances age out.

Where does this advice stop being true?+

If you hold balances for one party, in one currency, at a volume where a discrepancy is a ten-minute query, this is more process than the problem deserves. Keep your column. The guide is written for the point where a mistake is somebody else money.

Dual-write into a test ledger first.

Test mode is the whole API with no card and no live books. Point your dual-write at it, run the comparison for a week, and see what it finds before anything is real.