Payroll run
Design a payroll system that pays thousands of companies' employees on schedule, with taxes and deductions, exactly once.
- 1A run is a state machine: draft, approved, processing, then paid or partially failed. Each move is a conditional UPDATE.
- 2The run is a batch of small transactions, one per employee. The item status is the checkpoint.
- 3Every money movement is a ledger entry with a unique key, and every payment carries an instruction key the bank sees.
- 4Tax rules are versioned with an effective date. The pay date picks the version, and the item keeps it.
| question | answer 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. |
| requirement | target |
|---|---|
| Pay every employee on the pay date | if approved before the cut-off |
| No double payment, no missed payment | 0, checked by reconciliation |
| Gross to net exact | to the cent; lines sum to gross |
| Peak pay day processed | 250,000 paychecks in under 1 hour |
| Audit trail | who did what to every run, kept for years |
| Survive a crash mid-run | resume 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.
- 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.
| call | result |
|---|---|
| POST /companies/:id/runs | 201 draft run. 409 if a regular run for the period exists. |
| PUT /runs/:id/inputs | Hours, bonuses. 200 in draft, else 409. |
| POST /runs/:id/calculate | 202, then the run shows totals. |
| POST /runs/:id/approve | 200. 403 same person, 409 wrong state, 422 past the cut-off. |
| GET /runs/:id | State, totals, items, audit trail. |
| POST /runs/:id/reissue | 201 off-cycle run for the returned payments. |
| GET /employees/:id/paystubs | 200, 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.
- 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.
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
);- 1The run states. Moves between them are conditional UPDATEs.
- 2Two people for every run. The database refuses one person in both roles.
- 3A partial unique index: one regular run per company and period. Off-cycle runs are free.
- 4Every paycheck adds up to the cent.
- 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.
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.
| tool | capability | what it gives this design | also used for |
|---|---|---|---|
| Postgres | Conditional UPDATE and its row count | A state change happens only from the expected state. | Orders, workflows |
| Postgres | CHECK constraints | Two-person approval; lines that add up to gross; net never negative. | Balances, stock |
| Postgres | Partial unique index | One regular run per company and period. | One active row per key |
| Postgres | FOR UPDATE SKIP LOCKED | Workers share a run, each employee to one worker. | Job queues |
| Postgres | Partial index on unposted items | Finding the next employee never walks the posted ones. | Queues, outboxes |
| Postgres | Unique keys and ON CONFLICT DO NOTHING | One accrual, one settlement and one instruction per employee per run. | Idempotent APIs |
| Postgres | Deferred constraint trigger | Every ledger entry sums to 0 at commit. | Rules across rows |
| Postgres | Transactions; rollback when a connection ends | A crashed worker leaves nothing half done. | Every multi-row change |
| Postgres | FULL OUTER JOIN over unnest() | Matches the bank result file with the instructions, both ways. | Any reconciliation |
| Service | Integer arithmetic, half-up rounding | Gross to net to the cent, the same on every run. | Invoices, tax |
| Bank | File ids, line keys, result files | A 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.
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- 1Tax tables are rows with an effective date. Nobody edits a version; a change adds one.
- 2Annualise, apply the yearly brackets, then divide back to one period.
- 3The social tax stops when the year’s wages reach the base, so the run reads year-to-date gross.
- 4A garnishment is capped at a share of disposable pay.
- 5Net is the remainder, so no rounding error can make the lines disagree with gross.
Tested source Go: gross to net
// 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
}
| method | status |
|---|---|
| Amounts as floats | Not approved |
| Integer cents, round each line once | Approved |
| Rates in code constants | Not approved |
| Versioned tables with effective dates | Approved |
| Recompute a paid paycheck with new rules | Not approved |
| Off-cycle run for the difference | Approved |
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.
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- 1Approving twice, or approving a run that is processing, changes 0 rows and is refused.
- 2The rule lives in the table, so no code path can skip it.
Tested source Go: approve and transition · SQL: transition
// 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
}
-- 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(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- 1Start and resume are the same call. A crashed run needs no repair step.
- 2A second worker takes the next employee instead of waiting for this one.
- 3A unique key on the entry. A replay finds it and posts nothing.
- 4Commits with the entry and the instruction. All three happen, or none.
Tested source Go: process · SQL: next item, entry, instruction
// 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
}
-- 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 method | status |
|---|---|
| One transaction for the whole run | Not approved |
| One transaction per employee, status as checkpoint | Approved |
| A queue message per employee | Inbox table |
| Offset in memory, restart from 0 | Not 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.
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- 1A full outer join shows lines the bank never got and lines payroll never sent.
- 2Key settle:<instruction>. Reconciling the same file again posts nothing.
- 3The liability stays on the books until a reissue pays it.
Tested source Go: reconcile · SQL: match both ways
// 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
}
-- 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 from | stopped by |
|---|---|
| Scheduler fires twice | Unique regular run per period |
| Worker resumes after a crash | Item status, entry key |
| Two workers on one run | SKIP LOCKED, instruction key |
| Bank file sent twice | File id and line keys at the bank |
| Result file loaded twice | Settle key, instruction status |
| account | what it is | end of run |
|---|---|---|
| deductions_payable | owed to funds, insurers, courts | $-1,351.92 |
| employee_tax_payable | owed to the tax office | $-3,826.73 |
| employer_tax_expense | expense | $1,370.38 |
| employer_tax_payable | owed to the tax office | $-1,370.38 |
| net_pay_payable | owed to employees | $0.00 |
| wages_expense | expense | $22,102.88 |
| cash | bank account | $-16,924.23 |
| sum | every 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.
See it: a run that crashes, resumes and pays once
recorded from Postgres; the database after each stepDatabase after this step
- regular run
- draft
- ledger
- 0 entries, sum 0
- paid from cash
- $0.00
- still owed
- $0.00
| item | payment | |
|---|---|---|
| 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.
| event | result | why it is safe | saved by |
|---|---|---|---|
| A worker dies mid-employee | Its transaction ends. | Postgres rolls back the entry and the instruction. A resume posts that employee once. | Transaction |
| Two workers resume at once | Both 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 twice | Second insert fails. | Partial unique index: one regular run per period. | Unique index |
| The preparer approves the run | The UPDATE fails. | CHECK approved_by <> prepared_by. | CHECK |
| Approval after the cut-off | Refused. | Pay it as an off-cycle run on the next banking day; tell the company now. | Cut-off |
| Bank file upload times out | The file is sent again. | The bank ignores a file id it has seen. | Bank |
| A payment is returned | The run is partially failed. | The net stays owed; a reissue pays it once, with no second accrual. | Ledger |
| The result file is loaded twice | Same lines again. | Instructions are no longer "sent", and the settle key exists. | Settle key |
| A rate changes mid-year | A new table version. | Paid items keep their version; runs after the effective date use the new one. | Versions |
| Deductions exceed the pay | Net would be negative. | The item is refused and the run waits in draft for a person. | CHECK |
| step | add | it handles | move up when you see |
|---|---|---|---|
| 1 | One 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. |
| 2 | N 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. |
| 3 | Partitions 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. |
| 4 | Shards 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.
| question | answer | deeper |
|---|---|---|
| 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 |
0 of 9 known
Why one transaction per employee and not one for the whole run?
A worker dies in the middle of employee 5. What is in the database?
Two workers resume the same run at once. Why does nobody get paid twice?
The bank file upload times out. Do you send it again?
Tax rates change on July 1. Which rates does a run paid on July 10 for June work use?
Why compute net as the remainder and not as its own formula?
A payment comes back because the employee's account is closed. What do you post?
Who can approve a run?
How do you correct an underpaid employee after the run is paid?
- 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.