Skip to main content

Why We Chose PostgreSQL for Our Fintech Stack

00:04:05:69

Beyond MySQL

For years, MySQL was our default. It's familiar, well-documented, and works fine for most CRUD applications. But as our fintech platforms grew — millions of trades, complex commission calculations, multi-currency accounting — we kept hitting MySQL's limitations.

PostgreSQL solved problems we were constantly working around.

ACID Compliance That Actually Works

Both MySQL and PostgreSQL claim ACID compliance, but PostgreSQL's implementation is more robust for financial workloads:

sql
-- PostgreSQL: Serializable isolation prevents phantom reads
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;

-- Check balance and withdraw atomically
SELECT balance FROM wallets WHERE client_id = 123 FOR UPDATE;

-- If balance sufficient, debit
UPDATE wallets SET balance = balance - 500.00
WHERE client_id = 123 AND balance >= 500.00;

-- This either succeeds completely or rolls back
COMMIT;

PostgreSQL's MVCC (Multi-Version Concurrency Control) handles concurrent transactions without the locking issues we experienced with MySQL's InnoDB under high write loads.

Table Partitioning for Trade Data

Trade tables grow fast. A broker with 10,000 active clients generates millions of rows per month. PostgreSQL's native partitioning keeps queries fast:

sql
CREATE TABLE trades (
    id           BIGSERIAL,
    client_id    INT NOT NULL,
    symbol       VARCHAR(20) NOT NULL,
    action       VARCHAR(10) NOT NULL,
    volume       DECIMAL(15,5) NOT NULL,
    open_price   DECIMAL(15,5) NOT NULL,
    close_price  DECIMAL(15,5),
    profit       DECIMAL(15,2),
    opened_at    TIMESTAMP NOT NULL,
    closed_at    TIMESTAMP,
    PRIMARY KEY (id, opened_at)
) PARTITION BY RANGE (opened_at);

-- Monthly partitions
CREATE TABLE trades_2026_01 PARTITION OF trades
    FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE trades_2026_02 PARTITION OF trades
    FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');

Queries that filter by date automatically hit only the relevant partition. Monthly reporting that used to take 45 seconds now completes in under 2 seconds.

Automate partition creation:

sql
-- Create next month's partition automatically
CREATE OR REPLACE FUNCTION create_monthly_partition()
RETURNS void AS $$
DECLARE
    next_month DATE := date_trunc('month', NOW() + INTERVAL '1 month');
    partition_name TEXT;
BEGIN
    partition_name := 'trades_' || to_char(next_month, 'YYYY_MM');

    EXECUTE format(
        'CREATE TABLE IF NOT EXISTS %I PARTITION OF trades
         FOR VALUES FROM (%L) TO (%L)',
        partition_name,
        next_month,
        next_month + INTERVAL '1 month'
    );
END;
$$ LANGUAGE plpgsql;

JSONB for Flexible Schemas

Fintech platforms deal with data that doesn't always fit rigid schemas. PSP responses vary wildly. MT4 and MT5 return different trade structures. Commission schemes have varied parameters.

PostgreSQL's JSONB columns handle this elegantly:

sql
CREATE TABLE transactions (
    id          BIGSERIAL PRIMARY KEY,
    client_id   INT NOT NULL,
    provider    VARCHAR(50) NOT NULL,
    amount      DECIMAL(15,2) NOT NULL,
    status      VARCHAR(20) NOT NULL,
    -- PSP-specific data stored as JSONB
    provider_data JSONB NOT NULL DEFAULT '{}',
    created_at  TIMESTAMP DEFAULT NOW()
);

-- Index specific JSON fields for fast queries
CREATE INDEX idx_transactions_provider_ref
    ON transactions ((provider_data->>'reference_id'));

-- Query JSON data naturally
SELECT * FROM transactions
WHERE provider_data->>'reference_id' = 'PAY-123456'
AND provider = 'payretailers';

This means adding a new PSP doesn't require schema migrations for PSP-specific fields. The core columns stay structured, and provider-specific data lives in JSONB.

Advanced Aggregations

Commission reporting requires complex aggregations across hierarchies. PostgreSQL's window functions and CTEs make this readable:

sql
-- Monthly commission report with running totals per IB
WITH monthly_commissions AS (
    SELECT
        c.ib_id,
        date_trunc('month', c.created_at) AS month,
        SUM(c.amount) AS total,
        COUNT(*) AS trade_count
    FROM commissions c
    WHERE c.created_at >= '2026-01-01'
    GROUP BY c.ib_id, date_trunc('month', c.created_at)
)
SELECT
    ib_id,
    month,
    total,
    trade_count,
    SUM(total) OVER (
        PARTITION BY ib_id
        ORDER BY month
        ROWS UNBOUNDED PRECEDING
    ) AS running_total
FROM monthly_commissions
ORDER BY ib_id, month;

Connection Pooling

PostgreSQL creates a new process per connection (unlike MySQL's thread-per-connection). Under high concurrency, this matters. Use PgBouncer:

ini
[databases]
crm = host=primary.rds.amazonaws.com port=5432 dbname=crm

[pgbouncer]
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 50

Transaction pooling mode lets 1,000 application connections share 50 actual database connections. This is essential when running multiple Laravel workers, queue processors, and API servers.

Performance Tuning

Key PostgreSQL settings for fintech workloads:

ini
# Memory
shared_buffers = 8GB              # 25% of RAM
effective_cache_size = 24GB        # 75% of RAM
work_mem = 256MB                   # Per-operation sort memory

# Write performance
wal_buffers = 64MB
checkpoint_completion_target = 0.9

# Query planning
random_page_cost = 1.1            # For SSD storage
effective_io_concurrency = 200     # For SSD storage

# Autovacuum (aggressive for high-write tables)
autovacuum_vacuum_scale_factor = 0.05
autovacuum_analyze_scale_factor = 0.02

Monitor with pg_stat_statements to find slow queries:

sql
SELECT query, calls, mean_exec_time, total_exec_time
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;

Key Takeaways

  1. PostgreSQL's ACID compliance handles concurrent financial transactions more robustly than MySQL
  2. Native partitioning keeps trade tables performant as they grow into millions of rows
  3. JSONB columns accommodate varying PSP response formats without schema migrations
  4. Window functions and CTEs make complex commission reporting readable and fast
  5. PgBouncer is essential for connection pooling under high concurrency
  6. Tune aggressively — default PostgreSQL settings are conservative for fintech workloads

The switch from MySQL to PostgreSQL was one of the highest-ROI infrastructure decisions we made. The querying capabilities alone justified the migration.

Want to discuss this topic or work together? Get in touch.

Contact me