System Design
C24

Payroll run

Design a payroll system that pays thousands of companies' employees on schedule, with taxes and deductions, exactly once.

Not startedSaved in this browser only.
  1. 1A run is a state machine: draft, approved, processing, then paid or partially failed. Each move is a conditional UPDATE.
  2. 2The run is a batch of small transactions, one per employee. The item status is the checkpoint.
  3. 3Every money movement is a ledger entry with a unique key, and every payment carries an instruction key the bank sees.
  4. 4Tax rules are versioned with an effective date. The pay date picks the version, and the item keeps it.
C24
    A

    The prompt and requirements

    questions first, then numbers
    questionanswer assumed
    How many companies and employees?10,000 companies, 50 employees on average: 500,000 people.
    Pay schedules?Weekly, every two weeks, twice a month, monthly. Set per company.
    Which taxes?Income tax by brackets, a social tax with a yearly wage base, an employer match. Rules change by date.
    How is money paid?One bank file per run. The bank pays on the pay date if the file arrives before its cut-off.
    Who approves?A second person, not the one who prepared the run.
    Corrections?Off-cycle runs. A paid run is never edited.
    Countries and currencies?One country first; the ledger keeps a currency per account.
    2 banking daysJul 3Jul 4Jul 5Jul 6Jul 7Jul 8Jul 9Jul 10period endscalculateapprovecut-off 17:00pay dateJun 22 to Jul 5draft runsecond personbank file outmoney arrivesMiss the cut-off and the bank cannot pay on the pay date.
    requirementtarget
    Pay every employee on the pay dateif approved before the cut-off
    No double payment, no missed payment0, checked by reconciliation
    Gross to net exactto the cent; lines sum to gross
    Peak pay day processed250,000 paychecks in under 1 hour
    Audit trailwho did what to every run, kept for years
    Survive a crash mid-runresume with no lost or extra payment

    I pay each employee once per run, on the pay date, from a run that a second person approved before the bank's cut-off.

    B

    Estimates

    arithmetic shown
    people
    10,000 × 50 = 500,000 employees
    paychecks
    500,000 × 26 periods = 13 million a year
    average
    13,000,000 ÷ 31.5 million s ≈ 0.4 a second
    peak day
    Half of all employees on one pay date: 250,000 paychecks
    processing
    250,000 ÷ 4,000 a second, 8 workers ≈ 63 s
    storage
    13 million × 1.8 kB ≈ 23 GB a year
    bank file
    One line per paycheck; one file per run

    Rates and the bytes per paycheck (items, instructions, entries, postings, with indexes) were measured in the lab.

    Payroll is a batch problem. The average is under 1 paycheck a second; the peak day is 250,000 paychecks before an evening cut-off.

    C

    API

    methods, bodies, status codes
    callresult
    POST /companies/:id/runs201 draft run. 409 if a regular run for the period exists.
    PUT /runs/:id/inputsHours, bonuses. 200 in draft, else 409.
    POST /runs/:id/calculate202, then the run shows totals.
    POST /runs/:id/approve200. 403 same person, 409 wrong state, 422 past the cut-off.
    GET /runs/:idState, totals, items, audit trail.
    POST /runs/:id/reissue201 off-cycle run for the returned payments.
    GET /employees/:id/paystubs200, one per run, with the tax version.

    POST calls take an Idempotency-Key header, stored with a unique index as on the money sheet.

    Every call that creates something takes an idempotency key, and every state change returns 409 when the run is in the wrong state.

    D

    Data model

    entities, then SQL
    payroll_runsPKidFKcompany_idkindregular, off-cyclepay_datepicks the rulescutoffapprove beforestate5 statesUQ one regular run a periodrun_itemsPKrun_idPKemployee_idFKtax_versiongross … netcentsstatuscheckpointCHECK lines sum to grosspayment_instructionsPKidempotency_keyemployee_idamount_cents= netstatussent, paidone per employee per runtax_tablesPKversionUQeffective_fromrulesjsonbnever edited, only addedjournal_entriesPKidUQidempotency_keyFKrun_idaccrue and settle, once eachpostingsPKentry_idPKaccountemployee_idamount+ debiteach entry sums to 0
    money
    bigint cents everywhere. Floats never touch an amount.
    tenancy
    company_id leads the employee key; scope queries per company.
    growth
    About 1.8 kB per paycheck. Partition items and postings by pay date.
    runs, items, instructionssql
    CREATE TABLE companies (
      id       bigint PRIMARY KEY,
      name     text   NOT NULL,
      schedule text   NOT NULL CHECK (schedule IN ('weekly', 'biweekly', 'semimonthly', 'monthly'))
    );
    
    CREATE TABLE employees (
      company_id    bigint NOT NULL REFERENCES companies (id),
      id            bigint NOT NULL,
      name          text   NOT NULL,
      annual_cents  bigint,             -- salaried, or
      hourly_cents  bigint,             -- paid by the hour
      retirement_bp int    NOT NULL DEFAULT 0,
      health_cents  bigint NOT NULL DEFAULT 0,
      garnish_cents bigint NOT NULL DEFAULT 0,
      bank_account  text   NOT NULL,
      PRIMARY KEY (company_id, id),
      CHECK ((annual_cents IS NULL) <> (hourly_cents IS NULL))
    );
    
    -- Versioned rules. A run uses the version in force on its pay date, and the item keeps it.
    CREATE TABLE tax_tables (
      version        text  PRIMARY KEY,
      effective_from date  NOT NULL UNIQUE,
      rules          jsonb NOT NULL
    );
    
    CREATE TABLE payroll_runs (
      id           bigint      GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
      company_id   bigint      NOT NULL REFERENCES companies (id),
      kind         text        NOT NULL CHECK (kind IN ('regular', 'off_cycle', 'reissue')),
      period_start date        NOT NULL,
      period_end   date        NOT NULL,
      pay_date     date        NOT NULL,
      cutoff       timestamptz NOT NULL,   -- approve before this, or the bank cannot pay on time
      state        text        NOT NULL DEFAULT 'draft'
        CHECK (state IN ('draft', 'approved', 'processing', 'paid', 'partially_failed'))1,
      prepared_by  text        NOT NULL,
      approved_by  text,
      CHECK (approved_by <> prepared_by)2   -- two people for every run
    );
    -- One regular run per company and period. A double click cannot make a second.
    CREATE UNIQUE INDEX one_regular_run ON payroll_runs (company_id, period_start) WHERE kind = 'regular'3;
    
    -- One row per employee per run. Its status is the checkpoint of the batch.
    CREATE TABLE run_items (
      run_id      bigint NOT NULL REFERENCES payroll_runs (id),
      company_id  bigint NOT NULL,
      employee_id bigint NOT NULL,
      tax_version text   REFERENCES tax_tables (version),
      gross       bigint NOT NULL,
      pre_tax     bigint NOT NULL,
      income_tax  bigint NOT NULL,
      social_tax  bigint NOT NULL,
      employer_tax bigint NOT NULL,
      post_tax    bigint NOT NULL,
      net         bigint NOT NULL CHECK (net >= 0),
      reissue_of  text,                    -- a returned payment this item pays again
      status      text   NOT NULL DEFAULT 'calculated' CHECK (status IN ('calculated', 'posted')),
      PRIMARY KEY (run_id, employee_id),
      FOREIGN KEY (company_id, employee_id) REFERENCES employees (company_id, id),
      CHECK (reissue_of IS NOT NULL OR gross = pre_tax + income_tax + social_tax + post_tax + net)4
    );
    -- Only the employees not yet posted, so finding the next one never walks the posted ones.
    CREATE INDEX run_items_todo ON run_items (run_id, employee_id) WHERE status = 'calculated';
    
    -- Append-only audit trail: who did what to which run.
    CREATE TABLE run_events (
      run_id bigint      NOT NULL REFERENCES payroll_runs (id),
      id     bigint      GENERATED ALWAYS AS IDENTITY,
      at     timestamptz NOT NULL DEFAULT now(),
      actor  text        NOT NULL,
      action text        NOT NULL,
      detail text        NOT NULL DEFAULT '',
      PRIMARY KEY (run_id, id)
    );
    
    -- One instruction per employee per run. The key goes to the bank with the payment.
    CREATE TABLE payment_instructions (
      idempotency_key text   PRIMARY KEY5,   -- run:<run>:emp:<employee>
      run_id          bigint NOT NULL REFERENCES payroll_runs (id),
      employee_id     bigint NOT NULL,
      amount_cents    bigint NOT NULL CHECK (amount_cents > 0),
      bank_account    text   NOT NULL,
      status          text   NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'sent', 'paid', 'returned')),
      return_reason   text
    );
    1. 1The run states. Moves between them are conditional UPDATEs.
    2. 2Two people for every run. The database refuses one person in both roles.
    3. 3A partial unique index: one regular run per company and period. Off-cycle runs are free.
    4. 4Every paycheck adds up to the cent.
    5. 5run:<run>:emp:<employee>. A second insert for the same employee and run does nothing.

    A run has one item per employee and one payment instruction per item. Each item writes a balanced ledger entry, and tax rules are rows with an effective date.

    E

    The architecture

    click a step; its path lights up
    Payroll adminbrowserSchedulerperiod endPayroll serviceruns, approvalsRun workersN, resumablePostgresruns, items, ledgerBankfiles in, results out

    Step 1: Schedule

    • At the end of each period, create a draft run per company. The partial unique index allows one regular run.
    • Collect hours, bonuses and changes until the run is approved.

    If it fails

    The scheduler fires twice: the second create fails on the unique index, and nothing changes.

    A scheduler creates the run, a person approves it, workers post one employee per transaction, and reconciliation with the bank closes it.

    F

    Capabilities used

    what each tool gives you
    toolcapabilitywhat it gives this designalso used for
    PostgresConditional UPDATE and its row countA state change happens only from the expected state.Orders, workflows
    PostgresCHECK constraintsTwo-person approval; lines that add up to gross; net never negative.Balances, stock
    PostgresPartial unique indexOne regular run per company and period.One active row per key
    PostgresFOR UPDATE SKIP LOCKEDWorkers share a run, each employee to one worker.Job queues
    PostgresPartial index on unposted itemsFinding the next employee never walks the posted ones.Queues, outboxes
    PostgresUnique keys and ON CONFLICT DO NOTHINGOne accrual, one settlement and one instruction per employee per run.Idempotent APIs
    PostgresDeferred constraint triggerEvery ledger entry sums to 0 at commit.Rules across rows
    PostgresTransactions; rollback when a connection endsA crashed worker leaves nothing half done.Every multi-row change
    PostgresFULL OUTER JOIN over unnest()Matches the bank result file with the instructions, both ways.Any reconciliation
    ServiceInteger arithmetic, half-up roundingGross to net to the cent, the same on every run.Invoices, tax
    BankFile ids, line keys, result filesA file sent twice pays once; returns come back as data.Payouts

    Postgres gives me constraints for money and approvals, unique keys for exactly-once, and SKIP LOCKED for a resumable batch.

    G

    Deep dive: gross to net

    pseudo code; one recorded paycheck
    one paycheckpseudo code
    gross_to_net(employee, period, ytd):
      rules = the tax table in force on the pay date1
      gross = salary ÷ periods, or hours × rate + overtime × 1.5 × rate
      pre_tax = retirement % of gross + health premium
      taxable = gross - pre_tax
      income_tax = brackets(taxable × periods) ÷ periods2
      social_tax = rate × min(gross, base - ytd)3
      post_tax = min(garnishment, 25% of what is left after tax4)
      net = taxable - income_tax - social_tax - post_tax5
      each line is rounded to a cent once
    1. 1Tax tables are rows with an effective date. Nobody edits a version; a change adds one.
    2. 2Annualise, apply the yearly brackets, then divide back to one period.
    3. 3The social tax stops when the year’s wages reach the base, so the run reads year-to-date gross.
    4. 4A garnishment is capped at a share of disposable pay.
    5. 5Net is the remainder, so no rounding error can make the lines disagree with gross.
    Tested source Go: gross to net
    Go: gross to netgo
    // GrossToNet computes one paycheck. Each line is rounded to the cent once; net is what remains,
    // so the lines always add up to gross exactly.
    func GrossToNet(e Employee, in Input, t TaxTable) (Paycheck, error) {
      periods, ok := PeriodsPerYear[in.Schedule]
      if !ok {
        return Paycheck{}, fmt.Errorf("unknown pay schedule %q", in.Schedule)
      }
      p := Paycheck{TaxVersion: t.Version}
    
      // 1. Earnings.
      switch {
      case in.SkipBase:
      case e.AnnualCents > 0:
        p.Gross = div(e.AnnualCents, periods)
      default:
        p.Gross = div(e.HourlyCents*in.Hundredths, 100) + div(e.HourlyCents*in.OTHundredths*3, 200)
      }
      p.Gross += in.BonusCents
    
      // 2. Pre-tax deductions lower the income that is taxed.
      p.Retirement = bp(p.Gross, e.RetirementBP)
      p.Health = min(e.HealthCents, p.Gross-p.Retirement)
      if in.SkipBase {
        p.Health = 0 // charged once a period, in the regular run
      }
      p.PreTax = p.Retirement + p.Health
      p.Taxable = p.Gross - p.PreTax
    
      // 3. Income tax: annualise, apply the brackets, divide back to one period.
      p.IncomeTax = div(annualTax(p.Taxable*periods, t.Brackets), periods)
    
      // 4. Social tax on gross, until the year's wages reach the base. The employer matches it.
      room := max(0, t.SocialBase-in.YTDGrossCents)
      p.SocialTax = bp(min(p.Gross, room), t.SocialBP)
      p.EmployerTax = bp(min(p.Gross, room), t.EmployerBP)
    
      // 5. Post-tax: a garnishment, capped at a share of what is left after tax.
      disposable := p.Taxable - p.IncomeTax - p.SocialTax
      p.Garnish = min(e.GarnishCents, bp(disposable, t.GarnishCapBP))
      p.PostTax = p.Garnish
    
      // 6. Net is the remainder.
      p.Net = p.Taxable - p.IncomeTax - p.SocialTax - p.PostTax
      if p.Net < 0 { // fixed deductions exceed the pay: a person reviews it before the run
        return Paycheck{}, fmt.Errorf("%w: employee %d, net %d", ErrNegativeNet, e.ID, p.Net)
      }
      return p, nil
    }
    
    Ana, salary $78,000.00 a yeargross$3,000.00pre-tax: retirement, health− $270.00income tax, rules 2026-07− $326.77social tax− $186.00post-tax: garnishment$0.00net$2,217.23
    methodstatus
    Amounts as floatsNot approved
    Integer cents, round each line onceApproved
    Rates in code constantsNot approved
    Versioned tables with effective datesApproved
    Recompute a paid paycheck with new rulesNot approved
    Off-cycle run for the differenceApproved

    The tax rules are examples, not any country's law. The lab tests 7 hand-worked paychecks and 10,000 random ones whose lines must add up to gross.

    I pick the tax table by pay date and round each line to a cent once. Net is the remainder, so the lines always add up to gross.

    H

    Deep dive: the run

    state machine, then a resumable batch
    draftapprovedprocessing✓ paidpartiallyfailedreissue rundraft → … → paidsecond person,before the cut-offa worker starts;others resumebank resultsreconciledone conditional UPDATE per arrow:SET state = to WHERE state = fromdraft only: calculate, recalculateafter draft: nobody edits an item
    approvepseudo code
    approve(run, approver, now):
      IF now > run.cutoff: refuse, past the cut-off
      UPDATE run SET state = approved, approved_by = approver
        WHERE state = draft1                      // 0 rows: wrong state
      CHECK approved_by <> prepared_by2           // in the table
      write an audit row: who, what, when
    1. 1Approving twice, or approving a run that is processing, changes 0 rows and is refused.
    2. 2The rule lives in the table, so no code path can skip it.
    Tested source Go: approve and transition · SQL: transition
    Go: approve and transitiongo
    // Approve moves a draft run to approved. A second person must approve, before the cut-off.
    func (p *Payroll) Approve(ctx context.Context, run int64, approver string, now time.Time) error {
      err := p.tx(ctx, func(tx pgx.Tx) error {
        var cutoff time.Time
        if err := tx.QueryRow(ctx, "SELECT cutoff FROM payroll_runs WHERE id = $1", run).Scan(&cutoff); err != nil {
          return fmt.Errorf("read run %d: %w", run, err)
        }
        if now.After(cutoff) {
          return fmt.Errorf("%w: approved at %s, cut-off %s", ErrPastCutoff, now.Format(time.RFC3339), cutoff.Format(time.RFC3339))
        }
        if err := transition(ctx, tx, run, Draft, Approved, &approver); err != nil {
          return err
        }
        return event(ctx, tx, run, approver, "approve", "")
      })
      var pgErr *pgconn.PgError
      if errors.As(err, &pgErr) && pgErr.Code == "23514" { // check_violation: approved_by <> prepared_by
        return fmt.Errorf("%w: run %d", ErrSameApprover, run)
      }
      return err
    }
    
    // transition is a conditional UPDATE: it changes the state only from the state given.
    func transition(ctx context.Context, tx pgx.Tx, run int64, from, to string, approver *string) error {
      tag, err := tx.Exec(ctx, q["transition"], run, from, to, approver)
      if err != nil {
        return fmt.Errorf("run %d %s to %s: %w", run, from, to, err)
      }
      if tag.RowsAffected() != 1 {
        return fmt.Errorf("%w: run %d is not %s", ErrState, run, from)
      }
      return nil
    }
    
    SQL: transitionsql
    -- Move a run from one state to the next. 0 rows: the run was not in the expected state.
    UPDATE payroll_runs SET state = $3, approved_by = coalesce($4, approved_by)
    WHERE id = $1 AND state = $2;
    process, resumablepseudo code
    process(run):
      approved -> processing, or resume if it is processing1
      LOOP:
        BEGIN
          item = next employee WHERE status = calculated
                 FOR UPDATE SKIP LOCKED2
          IF no item: STOP
          post the accrual entry, key accrue:run:employee3
          insert the payment instruction, key run:employee
          item.status = posted                   // the checkpoint4
        COMMIT
    1. 1Start and resume are the same call. A crashed run needs no repair step.
    2. 2A second worker takes the next employee instead of waiting for this one.
    3. 3A unique key on the entry. A replay finds it and posts nothing.
    4. 4Commits with the entry and the instruction. All three happen, or none.
    Tested source Go: process · SQL: next item, entry, instruction
    Go: processgo
    // Process pays every employee of an approved run, one transaction per employee. The item's
    // status is the checkpoint, so a worker that dies loses only the employee it was on, and any
    // worker can resume the run. It returns how many employees this call posted.
    func (p *Payroll) Process(ctx context.Context, run int64, worker string, f Fault) (int, error) {
      err := p.tx(ctx, func(tx pgx.Tx) error {
        err := transition(ctx, tx, run, Approved, Processing, nil)
        if errors.Is(err, ErrState) {
          return nil // already processing: this call resumes it
        }
        if err != nil {
          return err
        }
        return event(ctx, tx, run, worker, "start", "")
      })
      if err != nil {
        return 0, err
      }
      var state string
      if err := p.DB.QueryRow(ctx, "SELECT state FROM payroll_runs WHERE id = $1", run).Scan(&state); err != nil {
        return 0, fmt.Errorf("read run %d: %w", run, err)
      }
      if state != Processing {
        return 0, fmt.Errorf("%w: process run %d in state %s", ErrState, run, state)
      }
      posted, after := 0, int64(0) // after: the last employee this worker posted
      for {
        emp, err := p.payOne(ctx, run, after, posted+1 == f.CrashAt)
        switch {
        case err != nil:
          return posted, err
        case emp == 0 && after == 0:
          return posted, nil
        case emp == 0:
          after = 0 // sweep once more from the start for any employee skipped while locked
        default:
          posted, after = posted+1, emp
        }
      }
    }
    
    // payOne posts the next employee after the given one, in one transaction: the accrual entry,
    // the payment instruction and the checkpoint commit together. It returns the employee posted,
    // or 0 when none is left.
    func (p *Payroll) payOne(ctx context.Context, run, after int64, crash bool) (emp int64, err error) {
      conn, err := p.DB.Acquire(ctx)
      if err != nil {
        return 0, fmt.Errorf("acquire connection: %w", err)
      }
      defer conn.Release()
      tx, err := conn.Begin(ctx)
      if err != nil {
        return 0, fmt.Errorf("begin: %w", err)
      }
      defer func() {
        if rbErr := tx.Rollback(ctx); rbErr != nil && !errors.Is(rbErr, pgx.ErrTxClosed) && !crash {
          err = errors.Join(err, fmt.Errorf("rollback: %w", rbErr))
        }
      }()
      var it item
      err = tx.QueryRow(ctx, q["next_item"], run, after).Scan(&it.employee, &it.gross, &it.preTax, &it.incomeTax,
        &it.socialTax, &it.employerTax, &it.postTax, &it.net, &it.reissueOf, &it.account)
      if errors.Is(err, pgx.ErrNoRows) {
        return 0, nil
      }
      if err != nil {
        return 0, fmt.Errorf("next item of run %d: %w", run, err)
      }
      if it.reissueOf == nil { // a reissue pays a liability that the first run already accrued
        if err := accrue(ctx, tx, run, it); err != nil {
          return 0, err
        }
      }
      if crash {
        // The worker process dies here. Its connection ends, and Postgres rolls the transaction back.
        return 0, errors.Join(ErrCrashed, conn.Conn().Close(ctx))
      }
      key := fmt.Sprintf("run:%d:emp:%d", run, it.employee)
      if _, err := tx.Exec(ctx, q["instruct"], key, run, it.employee, it.net, it.account); err != nil {
        return 0, fmt.Errorf("instruction %s: %w", key, err)
      }
      if _, err := tx.Exec(ctx, q["item_posted"], run, it.employee); err != nil {
        return 0, fmt.Errorf("checkpoint employee %d: %w", it.employee, err)
      }
      if err := tx.Commit(ctx); err != nil {
        return 0, fmt.Errorf("commit employee %d: %w", it.employee, err)
      }
      return it.employee, nil
    }
    
    SQL: next item, entry, instructionsql
    -- The checkpoint: the first employee not yet posted, after the last one this worker posted.
    -- SKIP LOCKED lets a second worker take the next employee instead of waiting, never the same one.
    SELECT i.employee_id, i.gross, i.pre_tax, i.income_tax, i.social_tax, i.employer_tax, i.post_tax,
      i.net, i.reissue_of, e.bank_account
    FROM run_items i
    JOIN employees e ON e.company_id = i.company_id AND e.id = i.employee_id
    WHERE i.run_id = $1 AND i.status = 'calculated' AND i.employee_id > $2
    ORDER BY i.employee_id
    LIMIT 1
    FOR UPDATE OF i SKIP LOCKED;
    
    INSERT INTO journal_entries (idempotency_key, run_id) VALUES ($1, $2)
    ON CONFLICT (idempotency_key) DO NOTHING
    RETURNING id;
    
    INSERT INTO payment_instructions (idempotency_key, run_id, employee_id, amount_cents, bank_account)
    VALUES ($1, $2, $3, $4, $5)
    ON CONFLICT (idempotency_key) DO NOTHING;
    
    UPDATE run_items SET status = 'posted' WHERE run_id = $1 AND employee_id = $2 AND status = 'calculated';
    batch methodstatus
    One transaction for the whole runNot approved
    One transaction per employee, status as checkpointApproved
    A queue message per employeeInbox table
    Offset in memory, restart from 0Not approved

    Tested: a crash on each of the 8 employees in turn, then a resume; and 8 workers on one run of 400 employees. Every employee was posted once.

    Each state change is a conditional UPDATE. Processing is one transaction per employee, and the item status is the checkpoint any worker resumes from.

    I

    Deep dive: paying exactly once

    ledger, keys, bank file, reconciliation
    reconcilepseudo code
    reconcile(run, bank results):
      match results with the instructions sent, both ways1
      paid:     post the settle entry once2: owed -> cash
      returned: mark it returned; the net stays owed3
      IF nothing is open:
        run = paid, or partially_failed if any came back
    1. 1A full outer join shows lines the bank never got and lines payroll never sent.
    2. 2Key settle:<instruction>. Reconciling the same file again posts nothing.
    3. 3The liability stays on the books until a reissue pays it.
    Tested source Go: reconcile · SQL: match both ways
    Go: reconcilego
    // Reconcile matches the bank's results with the instructions sent. A paid line settles the
    // employee's net pay from cash; a returned line leaves it owed. The run ends paid, or
    // partially failed when any payment came back.
    func (p *Payroll) Reconcile(ctx context.Context, run int64, results []BankResult) (matches []Match, state string, err error) {
      err = p.tx(ctx, func(tx pgx.Tx) error {
        var keys, statuses, reasons []string
        var amounts []int64
        for _, r := range results {
          keys, amounts, statuses, reasons = append(keys, r.Key), append(amounts, r.Amount), append(statuses, r.Status), append(reasons, r.Reason)
        }
        rows, err := tx.Query(ctx, q["reconcile"], run, keys, amounts, statuses, reasons)
        if err != nil {
          return fmt.Errorf("reconcile run %d: %w", run, err)
        }
        matches, err = pgx.CollectRows(rows, func(r pgx.CollectableRow) (Match, error) {
          var m Match
          err := r.Scan(&m.Key, &m.Employee, &m.Amount, &m.Reason, &m.Result)
          return m, err
        })
        if err != nil {
          return fmt.Errorf("reconcile run %d: %w", run, err)
        }
        for _, m := range matches {
          switch m.Result {
          case "paid":
            if err := postEntry(ctx, tx, "settle:"+m.Key, run, m.Employee,
              []string{"net_pay_payable", "cash"}, []int64{m.Amount, -m.Amount}); err != nil {
              return err
            }
            fallthrough
          case "returned":
            if _, err := tx.Exec(ctx, q["settle_status"], m.Key, m.Result, m.Reason); err != nil {
              return fmt.Errorf("mark %s %s: %w", m.Key, m.Result, err)
            }
          }
        }
        var open, returned int
        if err := tx.QueryRow(ctx, q["open_instructions"], run).Scan(&open, &returned); err != nil {
          return fmt.Errorf("count open instructions: %w", err)
        }
        state = Processing
        if open > 0 {
          return nil // wait for the rest of the results
        }
        state = Paid
        if returned > 0 {
          state = PartiallyFailed
        }
        if err := transition(ctx, tx, run, Processing, state, nil); err != nil {
          return err
        }
        return event(ctx, tx, run, "engine", "reconcile", fmt.Sprintf("%d lines, %d returned", len(matches), returned))
      })
      return matches, state, err
    }
    
    SQL: match both wayssql
    -- Match the bank's result file with the instructions sent, both ways.
    WITH bank AS (
      SELECT * FROM unnest($2::text[], $3::bigint[], $4::text[], $5::text[]) AS b(key, amount, status, reason)
    ), sent AS (
      SELECT * FROM payment_instructions WHERE run_id = $1 AND status = 'sent'
    )
    SELECT coalesce(s.idempotency_key, b.key), coalesce(s.employee_id, 0), coalesce(s.amount_cents, 0),
      coalesce(b.reason, ''),
      CASE WHEN s.idempotency_key IS NULL THEN 'unknown to payroll'
           WHEN b.key IS NULL THEN 'missing at the bank'
           WHEN s.amount_cents <> b.amount THEN 'amounts differ'
           ELSE b.status END
    FROM sent s
    FULL OUTER JOIN bank b ON b.key = s.idempotency_key
    ORDER BY 1;
    copies come fromstopped by
    Scheduler fires twiceUnique regular run per period
    Worker resumes after a crashItem status, entry key
    Two workers on one runSKIP LOCKED, instruction key
    Bank file sent twiceFile id and line keys at the bank
    Result file loaded twiceSettle key, instruction status
    accountwhat it isend of run
    deductions_payableowed to funds, insurers, courts$-1,351.92
    employee_tax_payableowed to the tax office$-3,826.73
    employer_tax_expenseexpense$1,370.38
    employer_tax_payableowed to the tax office$-1,370.38
    net_pay_payableowed to employees$0.00
    wages_expenseexpense$22,102.88
    cashbank account$-16,924.23
    sumevery entry sums to 0$0.00

    Debits are positive. Accrual: wages and the employer's tax against what is owed. Settlement: net pay owed against cash.

    The ledger accrues each paycheck once, the bank pays each instruction key once, and reconciliation settles only what the bank says it paid.

    J

    See it: a run that crashes, resumes and pays once

    recorded from Postgres; the database after each step
    step 1 of 15

    Database after this step

    regular run
    draft
    ledger
    0 entries, sum 0
    paid from cash
    $0.00
    still owed
    $0.00
    itempayment
    1 Ana··
    2 Ben··
    3 Chen··
    4 Dee··
    5 Eli··
    6 Fay··
    7 Gus··
    8 Hal··

    The worker died on employee 5, and two resumes posted the rest once. The duplicate bank file changed nothing, and a reissue paid Fay once.

    K

    Failure cases

    what breaks, and why nobody is paid twice
    eventresultwhy it is safesaved by
    A worker dies mid-employeeIts transaction ends.Postgres rolls back the entry and the instruction. A resume posts that employee once.Transaction
    Two workers resume at onceBoth ask for the next employee.SKIP LOCKED gives each a different one; the instruction key backs it up.SKIP LOCKED
    The scheduler creates a run twiceSecond insert fails.Partial unique index: one regular run per period.Unique index
    The preparer approves the runThe UPDATE fails.CHECK approved_by <> prepared_by.CHECK
    Approval after the cut-offRefused.Pay it as an off-cycle run on the next banking day; tell the company now.Cut-off
    Bank file upload times outThe file is sent again.The bank ignores a file id it has seen.Bank
    A payment is returnedThe run is partially failed.The net stays owed; a reissue pays it once, with no second accrual.Ledger
    The result file is loaded twiceSame lines again.Instructions are no longer "sent", and the settle key exists.Settle key
    A rate changes mid-yearA new table version.Paid items keep their version; runs after the effective date use the new one.Versions
    Deductions exceed the payNet would be negative.The item is refused and the run waits in draft for a person.CHECK
    L

    Scale ladder

    start simple; climb only on a signal
    Each step adds one component1One Postgres, 1 worker2+ N workers3+ partitions4+ shards by companymore load →
    Capacity against the peak pay day101001k10k100kDemand, peak day in 1 hour: 69 paychecks per secondDemand, peak day in 1 hour69Calculate, 1 run: 1,400 paychecks per secondCalculate, 1 run1,400Process, 1 worker: 840 paychecks per secondProcess, 1 worker840Process, 8 workers: 4,000 paychecks per secondProcess, 8 workers4,000paychecks per second, log scale
    stepaddit handlesmove up when you see
    1One Postgres, one worker per run.About 840 paychecks a second. The peak day of 250,000 takes about 5 minutes.Large companies finish close to the cut-off.
    2N workers per run, with SKIP LOCKED.About 4,000 a second with 8 workers: the peak day in about 63 s.Items and postings pass memory; old years slow vacuum.
    3Partitions of items and postings by pay date.Old years move to cheap storage; a run touches the current partition.One primary nears its write limit, or a region needs its own data.
    4Shards by company, a run never crosses one.Each shard adds its own rate. Companies are independent.Top of the ladder.

    Demand, derived: 250,000 paychecks ÷ 3,600 s ≈ 69 a second. Measured rates come from the lab, so read them as orders of magnitude.

    One Postgres processes the peak pay day in about a minute with 8 workers. I partition by pay date, then shard by company when one primary is not enough.

    M

    Follow-ups

    what the interviewer asks next
    questionanswerdeeper
    How do you keep the ledger exact under retries?Each entry has a unique key claimed in the same transaction as its postings. A deferred trigger refuses an entry that does not sum to 0.T9
    Why not a distributed transaction with the bank?The bank is outside your database and works in files. Send idempotent instructions, then reconcile the results; returns are compensations.T7
    How do you tell employees and companies they were paid?Write an event in the same transaction as the settlement. A dispatcher sends it to an email service and to company webhooks.B6
    How do you keep companies apart?company_id leads every key, and row-level security scopes each request to one company. A very large company can get its own cell.O6
    How did you choose the keys and tables?From the access patterns: run by id, items by run, paystubs by employee and year. Each gets a key or an index.D3
    What locks does processing take?A row lock on one item at a time, skipped by other workers. No lock spans the run, so approvals and reads never wait.T4
    Is the run a stream or a batch?A batch with a checkpoint per record. The same resume rules apply as for a stream job with offsets.B7
    How do you shard when one database is not enough?By company_id, since a run never crosses companies. A directory maps each company to its shard.X2
    N

    Drill

    predict, then reveal

    0 of 9 known

    1. Why one transaction per employee and not one for the whole run?

    2. A worker dies in the middle of employee 5. What is in the database?

    3. Two workers resume the same run at once. Why does nobody get paid twice?

    4. The bank file upload times out. Do you send it again?

    5. Tax rates change on July 1. Which rates does a run paid on July 10 for June work use?

    6. Why compute net as the remainder and not as its own formula?

    7. A payment comes back because the employee's account is closed. What do you post?

    8. Who can approve a run?

    9. How do you correct an underpaid employee after the run is paid?

    O

    Numbers to say

    measured in the lab
    process
    About 840 paychecks a second with 1 worker, 4,000 with 8.
    calculate
    About 1,400 paychecks a second in one run.
    storage
    About 1.8 kB per paycheck with indexes.
    peak
    250,000 paychecks in about 63 s with 8 workers.
    year
    13 million paychecks, about 23 GB.
    crash
    Crash on any of 8 employees, resume: each paid once, ledger sum 0.

    Postgres 16 on an 8-core laptop, 10,000 employees per run, median of 4 runs. Other workloads shared the machine, so use these as orders of magnitude.