Postgres in Fintech

Why PostgreSQL is the default system of record for ledgers, wallets and payments, where it breaks, and how to run it for regulators in Egypt, Saudi Arabia and the UAE.

· · Updated · 30 min read

Dark typographic cover reading Postgres in Fintech with a blue Backend & Data label
In this article

Why Postgres matters in fintech

Every fintech product is, underneath the app, a promise about numbers. A wallet promises that the balance on the screen is the money the customer can spend. A payment gateway promises that a merchant was paid exactly once per captured authorisation. The database is where those promises are kept or broken, and for most companies building in Egypt, Saudi Arabia and the UAE today, that database is PostgreSQL.

I say "most" deliberately; there are fintechs on MySQL, Oracle, document stores and distributed SQL. But when I sit with a founding team to pick the system of record, Postgres is the default and everything else has to argue its way in. This article is about why, where the default stops being right, and what it takes to run Postgres in a regulated money business.

The database is the product

In a content app the database is plumbing. In a fintech the database is the product: the balance is a query, the statement is a query, the regulatory return is a query. So the questions change. You stop asking "how many requests per second" first and start asking "can two concurrent transfers leave the books unbalanced", "can anyone edit history", and "can I prove to an auditor what the balance was at 23:59 on the last day of the quarter".

Postgres answers with features that are old, well documented and boring: real transactions with a serializable isolation level, constraints the engine enforces rather than the application, triggers a careless service cannot bypass, write-ahead logging that gives you point-in-time recovery, and a permission model fine enough to stop your own API from reading another tenant's rows.

Correctness is a feature regulators can read

A compliance officer at a Saudi bank partner or an inspector from the Central Bank of Egypt will not read your Go code. They will read your schema, your access controls and your audit logs, or the documents describing them. A ledger whose invariants live in CHECK constraints and triggers is easier to explain than one whose invariants live in a service that "always" calls the right function. The database becomes the place where the rules are written down in a form both an engineer and an auditor can verify.

Boring is a compliment

In payments, the most valuable property of a technology is that nothing surprising happens at 3 a.m.

Postgres ships a major version every year, the on-disk format is stable, the failure modes are catalogued, and when something goes wrong the answer is usually a search away. For a fintech CTO that predictability is worth more than any benchmark.

Where it is used

Public examples

Postgres is unusual in how much of its production usage is written up in public. Instagram's early engineering posts described sharding on PostgreSQL and generating IDs inside PL/pgSQL functions while the company was growing by millions of users a month. Skype ran its backend on Postgres and gave the world PgBouncer and PL/Proxy in the process. Stripe's write-up of its internal ledger says little about storage but is worth reading for the design: double entry, immutability and continuous reconciliation against external systems.

On the platform side, the strongest signal is where the cloud providers put their money: Amazon Aurora PostgreSQL and RDS, Google Cloud SQL and AlloyDB, Azure Database for PostgreSQL, Supabase and Neon are all bets that the market wants Postgres, managed, and several now run in Gulf regions. At the other end sits TigerBeetle, a database built only for double-entry accounting at very high transaction rates; it is the clearest statement of what a ledger-specialised store looks like.

Inside a typical fintech stack

In a wallet, a BNPL product or a payment gateway built in the region, Postgres typically holds the ledger (accounts, journal entries, entry lines and the balances derived from them), the customer graph (identities, KYC state, limits, consents, devices), the operational state of payments (authorisations, captures, refunds, settlement batches, reconciliation results) and the configuration that must stay consistent with money movement, such as fees and FX rates. What it usually does not hold is the clickstream, the raw event firehose from a card processor, or dashboard aggregates over years of history; those live in object storage, a queue or an analytical database fed from Postgres, not instead of it.

In MENA specifically

Three things shape usage across Egypt, Saudi Arabia and the UAE. Licensing regimes (CBE, SAMA, CBUAE and the free-zone regulators) push core financial data in-country or in-region, which makes managed Postgres in Gulf regions decisive. DBA talent is scarce, so small, well-understood deployments beat clever ones. And small teams moving fast gain the most from a database that enforces invariants for them.

Strengths

Transactions you can reason about

Postgres implements multi-version concurrency control, so readers never block writers and writers never block readers. It offers the three isolation levels that matter in practice, and its SERIALIZABLE implementation (serializable snapshot isolation) delivers what the name promises without locking everything. The official transaction isolation chapter is short, and every engineer touching money should have read it.

Constraints as the last line of defence

NOT NULL, CHECK, UNIQUE, foreign keys, exclusion constraints and deferrable constraint triggers let you encode "an entry must balance", "a wallet cannot go below its overdraft limit" and "no two active cards share a PAN hash" in a place that every code path has to pass through. Application code has bugs; constraints turn those bugs into loud errors rather than silent corruption.

A type system built for money and time

NUMERIC gives exact decimal arithmetic with a precision you choose. timestamptz stores an instant and converts to the session's zone, which saves you from the Cairo, Riyadh and Dubai offset bugs that plague systems storing naive timestamps. JSONB keeps the raw processor response next to the normalised row, indexed, without a document database. Range types and exclusion constraints model validity periods for fees and rates cleanly.

Extensions and ecosystem

pgcrypto for hashing and symmetric encryption, pg_partman for partition maintenance, pg_stat_statements for finding the query that is hurting you, PostGIS for geofencing an agent network, and logical replication with the change-data-capture tools built on it, Debezium being the common one. "We need X" is almost always answered by something mature rather than something you write yourself.

Risks and pitfalls

Treating the database as a dumb store

The most common failure I see is a team that uses Postgres as a key-value store behind an ORM, keeps every rule in application code, and then discovers that two services disagree about what a balance is. If your invariants are not in the schema, you do not have invariants; you have conventions.

Floating point and silent rounding

Never store money in float or double; this is well known and still happens. Less well known: NUMERIC(20,4) silently rounds a value with more than four decimal places on insert, so a fee computed at six places lands in the ledger as a different number.

Read Committed surprises

The default isolation level is READ COMMITTED. It is fine for most reads and dangerous for read-modify-write on money: two transactions can both read a balance of 100, both decide that 80 can be withdrawn, and both commit. This is the table I draw on a whiteboard when a new engineer joins.

Isolation level What a transaction sees Anomalies still possible Use in a ledger
READ COMMITTED (default) A fresh snapshot per statement Non-repeatable reads, lost updates on read-modify-write, write skew Reporting reads and idempotent inserts; never for balance checks
REPEATABLE READ One snapshot for the whole transaction Write skew (two transactions each read, then both write) Reads that must be consistent across several queries
SERIALIZABLE As if transactions ran one after another None; the engine aborts with 40001 instead Money movement, limit checks, anything with an invariant across rows

Mutable ledgers

If a ledger table allows UPDATE or DELETE, someone will use it to "fix" a row, and six months later nobody will be able to explain why the month-end balance does not match the bank statement. Corrections are new entries that reverse old ones. The schema below enforces this with a trigger; the organisation enforces it by never granting the permission.

Long transactions, bloat and wraparound

MVCC keeps old row versions until no transaction can see them. A reporting job that holds a transaction open for an hour, or an idle-in-transaction connection from a forgotten script, blocks vacuum, inflates tables and, in the extreme, walks you toward transaction ID wraparound, where Postgres refuses writes to protect itself. The routine vacuuming chapter explains the mechanism; idle_in_transaction_session_timeout and an alert on the age of the oldest transaction keep you from finding out in production.

Connection storms and the "managed" illusion

Each Postgres connection is an operating-system process. A fleet of autoscaled API pods opening twenty connections each will exhaust max_connections on the first traffic spike; a pooler is not optional in production. And managed services remove hardware, patching and basic backups, but not the need for someone to own query plans, index hygiene, vacuum settings and restore drills. The pitfall is believing that the "managed" label covers all of that.

Architecture and integration patterns

The ledger as the core

Everything in a fintech data model hangs off the ledger, so design it first, and design it as accounting rather than as a balances table you overwrite. My conventions: one journal entry per business event, with an external reference that makes it idempotent; signed line amounts, debit positive and credit negative, so that "balanced" means "sums to zero"; balances derived from lines, with a materialised running balance only where throughput requires it; and no UPDATE or DELETE on ledger tables, ever, because reversals are new entries.

A double-entry ledger in plain SQL

-- Chart of accounts. Customer wallets are liabilities: money we owe the customer.
CREATE TABLE accounts (
    id          BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    code        TEXT NOT NULL UNIQUE,
    currency    CHAR(3) NOT NULL,
    kind        TEXT NOT NULL CHECK (kind IN ('asset', 'liability', 'equity', 'revenue', 'expense')),
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- One journal entry per business event: a transfer, a fee, a reversal.
CREATE TABLE journal_entries (
    id            BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    external_ref  TEXT NOT NULL UNIQUE,      -- payment id, request id, batch id
    description   TEXT NOT NULL,
    posted_at     TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Signed amounts: debit is positive, credit is negative, so every entry sums to zero.
CREATE TABLE entry_lines (
    id          BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    entry_id    BIGINT NOT NULL REFERENCES journal_entries(id),
    account_id  BIGINT NOT NULL REFERENCES accounts(id),
    amount      NUMERIC(20,4) NOT NULL CHECK (amount <> 0),
    currency    CHAR(3) NOT NULL
);

-- Balance check, deferred to COMMIT so an entry can be inserted line by line.
CREATE OR REPLACE FUNCTION assert_entry_balanced() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
    IF (SELECT sum(amount) FROM entry_lines WHERE entry_id = NEW.entry_id) <> 0 THEN
        RAISE EXCEPTION 'journal entry % does not balance', NEW.entry_id
              USING ERRCODE = 'integrity_constraint_violation';
    END IF;
    RETURN NULL;
END $$;

CREATE CONSTRAINT TRIGGER entry_lines_balanced
AFTER INSERT ON entry_lines
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE FUNCTION assert_entry_balanced();

-- Append-only: ledger rows are never edited or deleted, only reversed by a new entry.
CREATE OR REPLACE FUNCTION forbid_mutation() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
    RAISE EXCEPTION '% is append-only; post a reversing entry instead', TG_TABLE_NAME;
END $$;

CREATE TRIGGER entry_lines_append_only
BEFORE UPDATE OR DELETE ON entry_lines
FOR EACH ROW EXECUTE FUNCTION forbid_mutation();

Tested with PostgreSQL 16.

Three things in this schema do real work. CHECK (amount <> 0) stops zero lines that hide bugs. The constraint trigger is DEFERRABLE INITIALLY DEFERRED, so an entry can be inserted as several statements inside one transaction and is only checked at commit, where an unbalanced entry fails the whole transaction. The append-only trigger turns a well-meaning UPDATE entry_lines SET amount = ... into an error that says what to do instead. Add the same trigger to journal_entries, and keep currency on lines even though accounts carry one: multi-currency ledgers eventually need it.

A transfer that survives retries and concurrency

-- Idempotency: one row per client request key, reused to answer retries.
CREATE TABLE idempotency_keys (
    key         TEXT PRIMARY KEY,
    entry_id    BIGINT REFERENCES journal_entries(id),
    response    JSONB,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Retry policy, implemented in the application around this whole block:
--   * SQLSTATE 40001 (serialization_failure) or 40P01 (deadlock_detected):
--     roll back, wait with jittered backoff, retry the whole transaction (max ~5).
--   * SQLSTATE 23505 on idempotency_keys: another worker owns this request;
--     return its stored response, or 409 if it is still in flight. Do not retry.
BEGIN ISOLATION LEVEL SERIALIZABLE;

-- 1. Claim the request key first; duplicates fail here before touching balances.
INSERT INTO idempotency_keys (key) VALUES ('req_7f3a');

-- 2. Lock both accounts in a fixed order (lowest id first) so concurrent
--    transfers 101->202 and 202->101 queue instead of deadlocking.
SELECT id, currency FROM accounts WHERE id IN (101, 202) ORDER BY id FOR UPDATE;

-- 3. Available balance of the source wallet. Wallets are liabilities, so the
--    credit balance is the negated sum. The application aborts if it is < 250.
SELECT -coalesce(sum(amount), 0) AS available
FROM entry_lines WHERE account_id = 101;

-- 4. Post the entry: debit the sender's wallet, credit the receiver's wallet.
WITH je AS (
    INSERT INTO journal_entries (external_ref, description)
    VALUES ('req_7f3a', 'wallet transfer 101 -> 202')
    RETURNING id
)
INSERT INTO entry_lines (entry_id, account_id, amount, currency)
SELECT je.id, v.account_id, v.amount, 'EGP'
FROM je, (VALUES (101, 250.0000), (202, -250.0000)) AS v(account_id, amount);

-- 5. Store the outcome so a retried request returns the same answer.
UPDATE idempotency_keys
SET entry_id = (SELECT id FROM journal_entries WHERE external_ref = 'req_7f3a'),
    response = jsonb_build_object('status', 'posted', 'amount', '250.0000')
WHERE key = 'req_7f3a';

COMMIT;  -- the deferred balance trigger runs here and can still reject the entry

Tested with PostgreSQL 16.

Two mechanisms overlap on purpose. SERIALIZABLE guarantees that no interleaving of concurrent transfers can produce an outcome that was impossible serially; if it detects one, it aborts with SQLSTATE 40001 and the application retries. The SELECT ... FOR UPDATE in ascending id order makes the common case cheap: two transfers touching the same wallet queue behind each other instead of racing to a serialization failure, and the fixed ordering means they can never deadlock. The idempotency key is claimed before any balance is read, so a client retrying after a timeout gets its stored response or a clean conflict, never a second debit.

Balance checks against sum(amount) are correct and, with the covering index shown later, fast enough for most products; the few accounts that take most of the writes are handled under performance.

Idempotency at the edge, outbox at the exit

Idempotency keys belong at the API boundary and inside the database as shown, never only in a cache. For events leaving the system, the transactional outbox keeps you honest: write the event row in the same transaction as the ledger entry and let a relay publish it to Kafka or your queue. Postgres' own logical replication and the CDC tools built on it, Debezium among them, can replace the relay by streaming committed rows to consumers without dual writes.

Multi-tenancy choices

Payment platforms, banking-as-a-service and merchant acquiring products serve many tenants from one codebase. There are three honest options; shared tables with row-level security is my default for new products, and the schema under security shows it.

Model Isolation Operational cost When it fits
Database per tenant Strongest; separate backups and residency per tenant Highest; migrations and monitoring multiply A few large, regulated tenants such as banks and telcos
Schema per tenant Strong; one cluster, separate namespaces Medium; pooling and migrations get awkward past hundreds of tenants Tens of mid-size tenants with custom needs
Shared tables with tenant_id and RLS Enforced by the engine per row Lowest; one schema, one migration Many small tenants, SaaS-style fintech, marketplaces

Security and compliance

The frameworks you will meet

Depending on the product and the country, the frameworks that shape database decisions are PCI DSS if you touch card data; the licensing and outsourcing rules of SAMA in Saudi Arabia, CBUAE in the UAE and CBE in Egypt; the data protection laws in each country (the Saudi PDPL, the UAE federal data protection law with the DIFC and ADGM regimes, Egypt's personal data protection law); and, for open-banking APIs or European customers, PSD2 and the regional open banking frameworks. All of them ask the same questions: where is the data, who can read it, who changed it, can you restore it, and how long do you keep it.

Encryption at rest and in transit

Encrypt the volumes (every managed service does this by default, usually with a key you can bring) and require TLS on every connection, including inside the VPC: ssl = on with hostssl rules in pg_hba.conf, or the managed equivalent, plus sslmode=verify-full from the clients. Disk encryption satisfies the "at rest" checkbox; it does nothing against a leaked database credential, which is the realistic threat.

pgcrypto versus application-level encryption

pgcrypto gives you digest, hmac and pgp_sym_encrypt inside SQL. It is right for hashing identifiers you need to look up, with a server-side pepper, and for low-volume secrets. It is wrong as the primary protection for card data or document numbers, because the key has to be present in the database session and shows up in logs and pg_stat_statements if you are careless. For those fields, encrypt in the application with a key from a KMS or an HSM, store the ciphertext plus a blind index for lookups, and keep the key out of the database. For card data, the strongest move is not to store it: tokenise with a PCI-scoped provider so your Postgres never enters scope.

Roles and least privilege

One role per service, no shared superuser, no owner role used by an application. The API role gets SELECT and INSERT on ledger tables and nothing else; UPDATE and DELETE are not granted even though the trigger would block them. Humans connect through a bastion or a managed proxy with short-lived credentials and named roles, and every session is logged. Keep the migration role separate from the application role, so a compromised API credential cannot alter triggers.

Row-level security and an audit trail

-- Every tenant-owned table carries tenant_id. The API sets the tenant per
-- transaction: SET LOCAL app.tenant_id = '<uuid>'; SET LOCAL app.user_id = '<id>';
CREATE TABLE wallets (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id   UUID NOT NULL,            -- references tenants(id)
    owner_ref   TEXT NOT NULL,
    status      TEXT NOT NULL DEFAULT 'active',
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX wallets_tenant_idx ON wallets (tenant_id);

ALTER TABLE wallets ENABLE ROW LEVEL SECURITY;
ALTER TABLE wallets FORCE ROW LEVEL SECURITY;   -- the table owner is not exempt

-- missing_ok = true yields NULL when unset; a NULL comparison hides every row.
CREATE POLICY wallets_tenant_isolation ON wallets
    USING (tenant_id = current_setting('app.tenant_id', true)::uuid)
    WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid);

-- The API role is not the table owner and must never have BYPASSRLS.
CREATE ROLE app_api NOLOGIN;
GRANT SELECT, INSERT, UPDATE ON wallets TO app_api;

-- Audit trail with before/after images; a definer function writes it, so
-- app_api needs no privilege on audit_log and cannot edit or skip it.
CREATE TABLE audit_log (
    id          BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    tenant_id   UUID,
    table_name  TEXT NOT NULL,
    operation   TEXT NOT NULL,
    row_pk      TEXT,
    old_row     JSONB,
    new_row     JSONB,
    db_user     TEXT NOT NULL DEFAULT current_user,
    app_user    TEXT DEFAULT current_setting('app.user_id', true),
    at          TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
);

CREATE OR REPLACE FUNCTION audit_row_change() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public AS $$
DECLARE
    o JSONB := CASE WHEN TG_OP IN ('UPDATE', 'DELETE') THEN to_jsonb(OLD) END;
    n JSONB := CASE WHEN TG_OP IN ('INSERT', 'UPDATE') THEN to_jsonb(NEW) END;
BEGIN
    INSERT INTO audit_log (tenant_id, table_name, operation, row_pk, old_row, new_row)
    VALUES (coalesce(n ->> 'tenant_id', o ->> 'tenant_id')::uuid, TG_TABLE_NAME,
            TG_OP, coalesce(n ->> 'id', o ->> 'id'), o, n);
    RETURN NULL;
END $$;

CREATE TRIGGER wallets_audit
AFTER INSERT OR UPDATE OR DELETE ON wallets
FOR EACH ROW EXECUTE FUNCTION audit_row_change();

Tested with PostgreSQL 16.

Row-level security moves tenant isolation from "every query remembers the WHERE clause" to "the engine adds it". The API sets app.tenant_id with SET LOCAL at the start of each transaction, which behaves correctly behind a transaction-mode pooler because the setting dies with the transaction. The policy uses missing_ok = true so a request that forgot to set the tenant sees nothing, which is the safe failure. The audit function runs as its definer, so the application role needs no privilege on audit_log and cannot read or alter it.

Backups, PITR and the restore you actually tested

Continuous WAL archiving plus base backups give you point-in-time recovery: restoring the database as it was at 14:32:07, just before the bad deployment. None of it counts until you have restored to a fresh instance, run the reconciliation against the bank statement and timed the whole thing. Do it quarterly, write down the duration, and put it in your business continuity document; regulators ask.

Data residency and in-region managed services

Residency is not only where the primary runs. Replicas, backups, WAL archives, logical replication targets and the analytics warehouse all count. Draw the data flow and mark the region of every box; it is the first document a cloud or outsourcing approval will ask for.

Retention and the right to be forgotten

Financial records must be kept for years; personal data must be deleted on request. Those collide only if you put personal data in the ledger. Key the ledger by opaque account ids, keep personal data in its own tables, and satisfy erasure by deleting or crypto-shredding the personal rows while the ledger stays intact. Define retention per table in writing and implement it with partitions you detach and archive rather than DELETE statements that bloat the table and the WAL.

Performance and scaling

Connection pooling

Put PgBouncer (or your cloud's managed proxy) in transaction mode between the services and the database. It turns thousands of client connections into a few dozen server connections, which is what Postgres is happy with. The cost is that session-level features (named prepared statements, SET without LOCAL, advisory locks held across transactions) need care.

Indexes that earn their keep

Every index speeds some reads and slows every write: an insert into a ledger line with five indexes writes the heap page, five index pages and the WAL for all of them, then ships that WAL to every standby. The indexes that pay for themselves on a ledger: a composite on (account_id, posted_at DESC) for statements, made covering with INCLUDE (amount, currency) so balance sums never touch the heap; a partial index such as WHERE status = 'pending' on a payments table, which stays tiny because almost every row is terminal; and BRIN on the time columns of append-only tables, a few pages per partition instead of a B-tree the size of the data.

Partitioning the ledger by month

-- Partitioned version of entry_lines. The partition key must be part of every
-- unique constraint, so the primary key becomes (id, posted_at). bigserial rather
-- than an identity column: identity on partitioned tables arrived in PostgreSQL 17.
CREATE TABLE entry_lines (
    id          BIGSERIAL,
    entry_id    BIGINT NOT NULL REFERENCES journal_entries(id),
    account_id  BIGINT NOT NULL REFERENCES accounts(id),
    amount      NUMERIC(20,4) NOT NULL CHECK (amount <> 0),
    currency    CHAR(3) NOT NULL,
    posted_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (id, posted_at)
) PARTITION BY RANGE (posted_at);

-- One partition per month, bounded in UTC so DST changes never shift a boundary.
-- Create partitions ahead of time (pg_partman or a scheduled job).
CREATE TABLE entry_lines_2026_09 PARTITION OF entry_lines
    FOR VALUES FROM ('2026-09-01T00:00:00Z') TO ('2026-10-01T00:00:00Z');
CREATE TABLE entry_lines_2026_10 PARTITION OF entry_lines
    FOR VALUES FROM ('2026-10-01T00:00:00Z') TO ('2026-11-01T00:00:00Z');

-- A default partition keeps inserts from failing when a month is missing.
-- Alert when it is not empty: rows there mean the partition job did not run.
CREATE TABLE entry_lines_default PARTITION OF entry_lines DEFAULT;

-- Indexes declared on the parent are created on every partition, now and later.
-- 1. Statement history per account, newest first: the hottest query in a wallet.
--    INCLUDE makes it covering for balance sums, so no heap fetch is needed.
CREATE INDEX entry_lines_account_posted_idx
    ON entry_lines (account_id, posted_at DESC) INCLUDE (amount, currency);

-- 2. All lines of one journal entry: reversals, disputes, reconciliation.
CREATE INDEX entry_lines_entry_idx ON entry_lines (entry_id);

-- 3. BRIN on posted_at: a few pages per partition, ideal for append-only,
--    time-ordered data read by month-end reports that scan ranges.
CREATE INDEX entry_lines_posted_brin ON entry_lines USING brin (posted_at);

-- Partition pruning: a query with WHERE posted_at >= '2026-10-01' touches only the
-- matching partitions; confirm with EXPLAIN that the plan lists just those.

Tested with PostgreSQL 16.

Partitioning is not primarily a query speed-up; it is an operations tool. Monthly partitions keep each table and index small enough to vacuum and reindex quickly, let you detach and archive old months for retention instead of deleting rows, and keep the current month's working set in memory. Two cautions: attaching a new partition while the default partition holds rows forces a scan of the default, so keep it empty; and partitioning only helps queries that filter on the partition key, so statement queries must carry a posted_at range.

Vacuum, bloat and HOT updates

Postgres never updates a row in place; it writes a new version and leaves the old one for vacuum. Tables with frequent updates (a balances table, a payments table whose status changes five times) bloat if autovacuum cannot keep up. Set autovacuum_vacuum_scale_factor far below the default on hot tables, keep transactions short, and design for heap-only tuple updates: an update that touches no indexed column and fits in the same page writes no new index entries at all.

NUMERIC versus integer minor units

There are two correct ways to store money. NUMERIC(20,4) is self-describing, handles currencies with different minor units and sub-unit pricing, and is what most ledgers in the region use. BIGINT minor units (piastres, halalas, fils) are smaller, faster to sum and impossible to round silently, at the cost of a currency-aware conversion layer in every service. If you choose integers, store the currency exponent with the amount and never let a / 100 live in code without the currency beside it.

Hot accounts and lock contention

Every real ledger has a few accounts that appear in a large share of entries: the settlement float, the fee revenue account, a big merchant. Row locks on those accounts serialise all their transfers. Mitigations, in order of preference: do not lock the hot account when its invariant does not need it (a fee revenue account cannot go negative, so no balance check is required); split it into sub-accounts and consolidate in reporting; batch postings to the hot side; and only then look at a specialised ledger engine.

Read replicas and logical replication

Streaming replicas serve statements, dashboards and the reporting role without touching the primary, with a lag you must monitor and surface: "balance as of a few seconds ago" is acceptable in a dashboard and unacceptable in a limit check. Logical replication publishes committed changes per table to other Postgres instances or, through CDC, to the warehouse.

"Postgres is enough", honestly

A single well-tuned primary with replicas handles a volume that most fintechs in the region will not reach for years. The honest signals that you are leaving that envelope: the ledger's write rate is dominated by a few accounts you cannot split; analytical queries over years of lines compete with transfers on the same instance; event consumers need the change stream faster than the outbox relay delivers it. The answers are, respectively, a ledger-specialised store such as TigerBeetle or careful sharding, ClickHouse fed by CDC, and Kafka with Debezium.

Team and hiring in MENA

The DBA gap

Dedicated PostgreSQL DBAs are rare in Cairo, Riyadh and Dubai, and the few who exist are often inside banks and telcos on legacy systems. Most fintech teams will not hire one before Series A, and many never will. Plan for that rather than pretending the role will be filled.

Managed services as a hiring decision

A managed service buys you time, not accountability.

Choosing Aurora, Cloud SQL, Azure, Supabase or Neon is as much a staffing decision as a technical one: you are buying patching, failover, backups and a console in exchange for cost and some lock-in. For a team without a DBA it is the right trade almost every time, provided someone still owns what the provider does not: schema, indexes, vacuum settings, query plans and restore drills.

Upskilling backend developers on SQL

The practical path is to make two or three backend engineers deeply good at Postgres rather than waiting for a specialist. The curriculum writes itself: EXPLAIN (ANALYZE, BUFFERS) on real queries, reading pg_stat_statements weekly, understanding MVCC and vacuum, migrations that do not take long locks (concurrent index creation, NOT VALID constraints validated later), and the isolation levels table above until it is instinctive.

Interview signals

Good signals in a senior backend candidate: they can explain why READ COMMITTED allows a double spend and how to prevent it; they reach for a constraint before an if; they know how lock ordering avoids deadlocks; they have a defensible opinion on NUMERIC versus integers; they have restored a backup at least once. Weak signals: everything sits behind an ORM and they have never read a query plan, or "a document database for flexibility" without saying what consistency they are giving up.

Building the on-call muscle

Whoever owns the database should see the dashboards every day: replication lag, oldest transaction age, connections, bloat, cache hit ratio, slow queries. Runbooks for the five likely incidents (connection exhaustion, a long transaction blocking vacuum, failover, disk full, a bad migration) are a one-week investment that pays back at the first incident.

Decision framework

When Postgres is the right core

Postgres is the right system of record when the product is a ledger-shaped thing (wallet, lending, payments, BNPL, remittance, brokerage back office), when the team is small or has no DBA, when regulators need to understand your data model, and when the write volume is in the thousands of transactions per second or below.

When to add a specialised system

Add, do not replace, when you can measure a specific limit: a hot-account bottleneck that sub-accounts cannot split (ledger-specialised store), analytics that interfere with transactions (a columnar store fed by CDC), consumers that need a durable change stream (Kafka), or multi-region active-active writes required by the business rather than desired by the architecture (distributed SQL). Keep Postgres as the record and treat the addition as a derived system until it has earned trust.

Decision matrix

Option Transactions and invariants Ledger fit Ops burden without a DBA Residency in MENA Best for
Self-managed PostgreSQL Full: SERIALIZABLE, constraints, triggers, RLS Excellent High; you own everything Anywhere, including Egypt Teams with ops strength or on-premises rules
Managed PostgreSQL (Aurora, Cloud SQL, Azure, Supabase, Neon) Full Excellent Low to medium Gulf regions; Egypt limited Most fintech teams at launch and well beyond
MySQL / MariaDB Good; weaker serializable and constraint story, no RLS Good with discipline Low to medium Same as above Teams with existing MySQL expertise
CockroachDB / distributed SQL Serializable by default, distributed Good; higher latency per transaction Medium to high; new skills Multi-region by design Multi-region active-active requirements
TigerBeetle Purpose-built double-entry primitives Exceptional, for the ledger only Medium; new skills, needs a general database beside it Self-hosted anywhere Very high transaction rates on the ledger itself

The best database decision in a fintech is the one you can explain to a regulator, an auditor and a new engineer with the same diagram.

FAQ

Should I store money as NUMERIC or as integer minor units?

Both are correct; floats are not. NUMERIC(20,4) is the pragmatic default for mixed-currency ledgers and easier for auditors to read. Integer minor units are faster and cannot round silently but need a currency exponent everywhere. Choose one, write the rule down, and enforce the scale at the API boundary, because a typed NUMERIC column rounds rather than rejects.

Is SERIALIZABLE too slow for a payments system?

For money movement it is usually not the bottleneck; lock contention on hot accounts is. Use SERIALIZABLE for transactions that move money or check limits, READ COMMITTED for everything else, and keep both short. Measure the 40001 retry rate; if it climbs, fix the access pattern with lock ordering rather than dropping the isolation level.

Can one Postgres instance handle a national-scale wallet?

A single primary with read replicas can sustain thousands of ledger transactions per second when the schema is designed as above, which covers the daily volume of most wallets in the region with room to spare. Peaks (salary days, Ramadan evenings, a viral promotion) are handled by queueing at the edge, pooling and hot-account splitting, not by a different database. Shard or add a ledger-specialised store when measurements, not fear, say so.

Do I need a DBA before launch?

No, but you need an owner. Use a managed service, nominate one or two engineers to own schema, indexes, vacuum and restores, and give them time to learn. Bring in an external Postgres consultant for a quarterly review until the team is confident.

How do I handle "delete my data" requests with an immutable ledger?

Keep personal data out of the ledger. Ledger rows reference opaque account ids; names, documents and contact details live in their own tables with their own retention. Erasure deletes or crypto-shreds those rows while the financial record, which regulators require you to keep, remains intact and no longer identifies anyone.

Key takeaways

  • In fintech the database is the product; Postgres is the right default system of record for wallets, lending, payments and BNPL, and everything else must argue its way in.
  • Put invariants in the schema: double entry with lines that sum to zero, append-only ledger tables, CHECK constraints and deferrable triggers.
  • Move money in SERIALIZABLE transactions with lock ordering and idempotency keys; retry on 40001 and never double-debit.
  • Multi-tenancy is safest as shared tables with row-level security and SET LOCAL per transaction behind a transaction-mode pooler.
  • Compliance questions are the same under PCI DSS, SAMA, CBUAE and CBE: where the data lives, who can read it, who changed it, whether you can restore it, and how long you keep it.
  • Add Kafka, ClickHouse or TigerBeetle when you measure a limit, not when you anticipate one, and keep Postgres as the record.
  • Hire for SQL depth and database ownership rather than waiting for a DBA; managed services buy time but not accountability.