The Data Layer and Performance
ERP databases are large, wide, heavily indexed, and queried by people who don’t know SQL. Performance work here is a permanent job.
What ERP data actually looks like
| Characteristic | Reality |
|---|---|
| Table count | 5,000 – 100,000+ (SAP ECC ships ~90,000) |
| Columns per table | 50–500 is normal |
| Largest tables | Journal lines, material documents, change logs, deliveries — billions of rows |
| Growth | Append-heavy; almost nothing is deleted |
| Access pattern | Point lookups by document key + huge aggregations at close |
| Retention | 7–10 years minimum, often forever |
| Referential integrity | Enormous, often enforced in the application rather than the DB |
The indexing problem
An ERP table is queried on dozens of different key combinations, because different roles ask different questions.
journal_line, queried by:
document number (drill-down from a report)
account + period (trial balance)
cost centre + period (cost centre report)
customer + open items (AR ageing)
posting date range (period reports)
reference document (traceability)
created_by + date (audit)
You cannot index all of them without destroying write performance and doubling storage.
Strategy:
- The primary access path gets a covering index. Usually
(tenant, company, fiscal_year, document_number). - Secondary indexes for the top 3–5 real query patterns, measured from production, not guessed.
- Everything else goes to a read replica or a reporting store.
- Partition large tables by fiscal year or posting date. Most queries touch one or two periods; partition pruning is the single biggest win available on ledger tables.
- Archive cold data out of the hot tables.
Partitioning
CREATE TABLE journal_line (
...
posting_date DATE NOT NULL
) PARTITION BY RANGE (posting_date);
CREATE TABLE journal_line_2026 PARTITION OF journal_line
FOR VALUES FROM ('2026-01-01') TO ('2027-01-01');
Benefits: partition pruning on date-filtered queries, per-partition index maintenance, and — the underrated one — dropping a partition is instant where deleting the equivalent rows would take hours and bloat the table.
Choose the key carefully: partition by the column that appears in nearly every WHERE clause. For ledgers that’s posting_date or fiscal_year. For multi-tenant systems it may be tenant_id, or both (composite/sub-partitioning).
Aggregates: derived vs materialised
The eternal ERP trade-off.
| Approach | Consistency | Read speed | Write cost |
|---|---|---|---|
| Compute on read | Always correct | Slow at scale | Zero |
| Materialised totals, updated in the same transaction | Correct if the code is correct | Fast | Contention on hot rows |
| Materialised, updated asynchronously | Eventually correct | Fast | Low |
| Columnar / in-memory aggregation | Always correct | Fast | Zero |
Classic ERPs chose option 2 and carried dozens of totals tables. S/4HANA deleted most of them because option 4 became viable.
If you’re building on PostgreSQL/MySQL:
journal_line ← truth, append only
account_balance_period ← materialised: (company, ledger, account, dims, period)
updated in the same transaction as the posting
balance_reconciliation ← nightly job: rebuild from journal_line, compare, alert
The nightly reconciliation is non-negotiable. Materialised aggregates drift. Without a job that proves they still match, you find out from an auditor.
Hot row contention
Every posting to the same account/period updates the same balance row. Under load this serialises.
Mitigations:
- Sharded counters — N rows per key, summed on read, each transaction updating a random shard
- Asynchronous aggregation with a short lag
- Insert deltas instead of updating a balance —
balance_delta(key, amount)rows, periodically compacted. Turns an UPDATE hotspot into an INSERT, which scales far better
Query patterns to avoid
The N+1
for each order: -- 1 query
fetch order lines -- N queries
for each line:
fetch material -- N×M queries
The classic. In ERP it’s worse than usual because loops over document lines are everywhere. Batch-fetch and join in memory, or use a single joined query.
SELECT *
Wide ERP tables mean SELECT * transfers 300 columns to read 4. Always project explicitly.
Client-side filtering
-- Wrong
rows = SELECT * FROM journal_line WHERE company = 1000
filtered = rows.filter(r => r.posting_date >= start) -- 40M rows transferred
-- Right
SELECT ... FROM journal_line
WHERE company = 1000 AND posting_date BETWEEN start AND end
Obvious, and it still happens constantly in report code.
Unbounded queries
Every user-facing query needs a limit and a date range. “Show all customers” on a table with 2 million rows is a denial of service against your own system. Default the date range; force a filter.
Functions on indexed columns
WHERE YEAR(posting_date) = 2026 -- index unusable
WHERE posting_date >= '2026-01-01'
AND posting_date < '2027-01-01' -- index used
Implicit type conversion
Comparing a VARCHAR document number to an integer literal disables the index silently. ERP keys are frequently zero-padded strings ('0004500001'), which makes this a common trap.
Batch job performance
The close window is your peak. Techniques:
- Parallelise by natural key ranges — company code, plant, asset number range. Embarrassingly parallel in most cases.
- Commit in batches — every 1,000–10,000 records, not once at the end (a 6-hour transaction blocks vacuum/cleanup and makes recovery brutal) and not per record (commit overhead dominates).
- Bulk operations —
COPY, array binds, batch inserts. Row-by-row is 10–100× slower. - Restartability — checkpoint progress so a failure at 80% resumes rather than restarts.
- Read the data once. Many close jobs re-read the same base data; stage it.
- Avoid index churn — for very large loads, drop and rebuild non-essential indexes.
- Test at production volume. “Fast in DEV with 500 rows” is how go-live weekends get ruined.
Archiving
Data you must keep but rarely query:
Live (0–2 years) → hot tables, fully indexed
Near-line (2–5 years) → separate tablespace or columnar store, queryable, slower
Archived (5+ years) → archive files or object storage, retrievable on request
Purged → only after the legal retention period
Archiving is a real ERP function with a defined sequence: check the object can be archived (no open items, order closed, period closed), write to the archive, verify, then delete from the live table. Never delete before verifying the archive is readable.
Legal retention is typically 7–10 years for financial records, longer for some (personnel records, product liability, pharma batch records can be 30+ years). Purge before the retention period and you’ve committed an offence; keep personal data forever and you’ve breached GDPR. Both directions have legal consequences.
Monitoring
What to watch:
| Signal | Why |
|---|---|
| Slow query log | The obvious one |
| Top queries by total time | Optimise what runs 10,000×/day, not the one slow ad-hoc |
| Lock waits and deadlocks | Contention on hot rows |
| Table and index bloat | Append-heavy tables with updates |
| Table growth rate | Predicts when you’ll hit a wall |
| Batch job durations, tracked over time | A job creeping from 20 min to 3 hours is an early warning |
| Connection pool saturation | The batch window competing with users |
| Replication lag | Reports reading stale data |
| Missing/unused indexes | Both are problems |
Track batch job durations as a time series. The trend tells you months in advance that the close window will break.
Database choice
| DB | Fit |
|---|---|
| PostgreSQL | Excellent default. Partitioning, JSONB for flexible fields, strong consistency, extensions. What most new ERPs are built on |
| SQL Server | Dynamics ecosystem, strong tooling, columnstore indexes are genuinely good for reporting |
| Oracle | The legacy incumbent; partitioning and RAC are mature |
| SAP HANA | In-memory columnar; removes the OLTP/OLAP split. Expensive |
| MySQL/MariaDB | Workable (ERPNext uses it); weaker partitioning and analytics story |
| NoSQL for the ledger | No. You need multi-row ACID transactions |
| NoSQL for satellites | Fine — documents, logs, search, sessions, caches |
Practical rules
- Design the schema first. In ERP the schema is the architecture.
NUMERIC/DECIMALfor money. Never float.- Append-only for financial and inventory movements.
- Partition your biggest tables by date from day one. Adding partitioning to a 2-billion-row table later is a migration project.
- Materialise aggregates, and reconcile them nightly.
- Index deliberately, from measured production patterns.
- Every user query has a bound.
- Batch jobs: parallel, chunked commits, restartable, measured.
- Plan archiving before you need it.