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 LOCALper 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.