ERPNext Performance

The Five my.cnf Lines That Make ERPNext Feel Like a Different Product

Nine times out of ten, a slow ERPNext isn't ERPNext — it's a default MariaDB install with a 128MB buffer pool trying to serve a 20GB database. Live my.cnf calculator, benchmark recipes, and the safe restart procedure inside.

MManojJuly 24, 202614 min read
#erpnext#performance#mariadb#innodb
Share

ERPNext feels slow? Nine times out of ten it's not ERPNext — it's a default MariaDB install with a 128MB InnoDB buffer pool trying to serve a 20GB database. Frappe's bench installer sets up the schema and the charset; it does not tune the server. Five (well, seven) lines in a my.cnf override file, a restart, and the same box starts feeling like a different product. This guide computes those lines against your own RAM and DB size, shows you how to benchmark honestly before and after, and covers the safe restart procedure that doesn't leave Frappe half-broken.

A 40-second General Ledger on a c5a.2xlarge that costs 150 USD a month should insult you. It almost always traces back to the same handful of untuned InnoDB settings. I am Manoj, Frappe / ERPNext implementation lead at MithTech in Bengaluru — this is the tuning pass we run on every self-hosted client before we touch anything else.

Try it: your my.cnf, computed from your box

Type in your actual VPS RAM, your current DB size, and your realistic peak concurrent users. The calculator emits a drop-in .cnf file grounded in the sizing rules from the MySQL 8.0 Reference §15.8.3.1 and the canonical Frappe discuss thread on ERPNext slow performance.

CalculatorMariaDB Tunerlink

Live-computed my.cnf from your RAM, DB size and expected concurrent users.

Live calculator

Your my.cnf, computed from your box

buffer_pool_size
5.5G
log_file_size
1.4G
pool_instances
6
max_allowed_packet
64M
io_capacity
400
Your DB (6 GB) is larger than 70% of RAM. Buffer pool capped at 5.5G — expect I/O-bound queries on cold cache. Consider scaling RAM.
60-erpnext.cnf
# /etc/mysql/mariadb.conf.d/60-erpnext.cnf
# Generated for 8 GB RAM host, ~6 GB DB, 30 concurrent users.
# Drop this file in and restart MariaDB. See MySQL 8.0 Reference §15.8.3.1
# for buffer-pool sizing rationale.

[mysqld]
# --- InnoDB memory ------------------------------------------------------
innodb_buffer_pool_size       = 5.5G
innodb_buffer_pool_instances  = 6
innodb_log_file_size          = 1.4G
innodb_log_buffer_size        = 32M

# --- Write / flush behaviour -------------------------------------------
innodb_flush_method           = O_DIRECT
innodb_flush_log_at_trx_commit = 2
innodb_io_capacity            = 400
innodb_io_capacity_max        = 800

# --- Connection / packet -----------------------------------------------
max_allowed_packet            = 64M
max_connections               = 151
thread_cache_size             = 32

# --- Frappe charset (already set by Frappe's own conf.d file) ----------
character-set-server          = utf8mb4
collation-server              = utf8mb4_unicode_ci

Why the default install is starving your ERPNext

Debian and Ubuntu ship MariaDB with a config aimed at a laptop, not a production database. innodb_buffer_pool_size defaults to 128 megabytes. That is the total amount of RAM MariaDB will use to cache data pages, index pages, adaptive-hash-index entries, and change-buffer state combined.

Your ERPNext database, by the end of year one on a live company, is not 128 megabytes. Realistic sizes we see:

  • A single-company SME with a year of transactions: 2–4 GB.
  • An import-heavy trading company with GRN + LCV + Stock Ledger volume: 6–12 GB.
  • A three-year-old multi-company deployment with heavy journal traffic (like the c5a.2xlarge case documented in the forums — 570k Journal Entries, 3.3M GL Entries): 17+ GB.

On a 128M buffer pool, any query that touches more than a few thousand rows across tabGL Entry, tabStock Ledger Entry, tabPurchase Receipt Item or tabSales Invoice Item will evict its working set on every run. The pool cannot hold enough index pages to answer a General Ledger for a full financial year without going back to disk repeatedly.

But surely bench sets this up correctly?

It does not. This surprises people, so let's ground it.

Frappe's own repository, in the one MariaDB config file shipped with the ecosystem — raven/.github/mariadb-frappe.cnf used to bootstrap CI containers — contains, in full:

[mysqld]
character-set-client-handshake = FALSE
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci

[mysql]
default-character-set = utf8mb4

Charset. That is the entire server-level configuration Frappe cares about at install time. Everything else — buffer pool, log file, flush behaviour, connection limits — is left to whatever your distribution shipped, which is invariably tuned for a laptop.

The bench setup production command likewise touches nginx, supervisor, redis, and systemd — but not my.cnf. This is a deliberate separation: Frappe doesn't want to fight the distribution's package manager over a shared config file. It just means the tuning is on you.

Where the InnoDB defaults come from

MariaDB 10.6's out-of-box innodb_buffer_pool_size is 128M, innodb_log_file_size is 96M, innodb_io_capacity is 200, and max_allowed_packet is 16M. These are documented in the MariaDB InnoDB System Variables reference. They are safe defaults for a dev laptop and criminally undersized for anything running ERPNext.

InteractiveDefault vs Tuned Comparatorlink

Side-by-side of what the five my.cnf lines actually change.

Default MariaDB 10.6

Fresh apt-get install, zero tuning

128M buffer pool means every ERPNext query past a few MB working set hits disk.

Buffer pool128 MB
Log file48 MB
max_allowed_packet16 MB
io_capacity200 IOPS
P95 report latency8,200 ms
ERPNext-tuned (8 GB host)

Seven lines of my.cnf, one restart

Working set lives in RAM. Reports return in one screen refresh, not fifteen.

Buffer pool4,096 MB
Log file1,024 MB
max_allowed_packet64 MB
io_capacity800 IOPS
P95 report latency900 ms

The five (well, seven) my.cnf lines

Here is the block. The tuner above generates one calibrated to your inputs; the version below is a good starting point for a typical 8 GB VPS running a single ERPNext site with a 4–6 GB database.

# /etc/mysql/mariadb.conf.d/60-erpnext.cnf
[mysqld]
# --- InnoDB memory ---------------------------------------------
innodb_buffer_pool_size       = 5G      # 60–70% of RAM
innodb_buffer_pool_instances  = 5       # ~1 per GB of pool
innodb_log_file_size          = 1G      # ~25% of the pool
innodb_log_buffer_size        = 32M

# --- Write / flush behaviour -----------------------------------
innodb_flush_method           = O_DIRECT
innodb_flush_log_at_trx_commit = 2      # trade durability for throughput
innodb_io_capacity            = 400     # 200 = HDD, 400+ = SSD baseline

# --- Connection / packet ---------------------------------------
max_allowed_packet            = 64M     # required for large report imports
max_connections               = 200
thread_cache_size             = 32

What each of the "five that matter" actually does:

  • innodb_buffer_pool_size is the single most important variable in the file. It sets how much RAM InnoDB will use to cache data pages. The MySQL 8.0 Reference §15.8.3.1 recommends 50–75% of physical memory on a dedicated DB host, 25–50% on a shared host. For a typical ERPNext VPS where MariaDB shares the box with Frappe workers, Redis, nginx and supervisor, 60% is a sensible target.
  • innodb_log_file_size governs how much dirty-page work the log can absorb before a checkpoint stalls writes. Too small and you get flush storms during heavy write bursts (imports, month-end postings). ~25% of the buffer pool is the standard heuristic; 2G is a reasonable cap beyond which crash recovery gets uncomfortably long.
  • innodb_buffer_pool_instances splits the pool into shards to reduce mutex contention. One instance per gigabyte of pool, up to about 8, is fine for VPS-scale ERPNext.
  • max_allowed_packet at 64M matches the value the Frappe community consistently converges on (discuss.frappe.io/t/50411). Below this, large report exports, bulk imports, and some background jobs will crash mid-query with an opaque error.
  • innodb_io_capacity tells InnoDB how many IOPS your storage can sustain, which governs the background flush rate. The default 200 is right for a spinning disk. On any modern SSD (NVMe or even a cloud gp3 volume) you want 400 or higher; the calculator scales this with concurrent users.

The remaining two — innodb_flush_method = O_DIRECT and innodb_flush_log_at_trx_commit = 2 — trade a slice of ACID durability for a large throughput win. = 2 means the log is flushed to the OS on every commit but only fsync'd once per second, so a hard crash could lose up to one second of committed transactions. On a VPS with a UPS-backed data center and daily backups this is a trade almost everyone should make; on a bank ledger, do not.

InteractiveQuery Latency Explorerlink

Illustrative query-latency curve — before vs after tuning — as DB size grows.

Illustrative model

How report latency scales with DB size

0 GBDB size →50 GB
Default (128M pool) Tuned for host
Before tuning
1127 ms
P50 canned report
After tuning
268 ms
4.2× faster (illustrative)

This is a shape-of-the-curve model, not a benchmark. Real numbers depend on your queries, indexes, and disk. Measure your own P50/P95 with the recipes below before and after.

Where the config file actually lives

Do not edit the main my.cnf or mariadb.cnf. Distribution package upgrades will overwrite them and your careful tuning will vanish silently. Drop an override file into the conf.d directory instead — MariaDB reads every .cnf in there in alphabetical order, so 60-erpnext.cnf will win against the shipped defaults.

Debian / Ubuntu (bare metal or VPS)

Path: /etc/mysql/mariadb.conf.d/60-erpnext.cnf. Reload with sudo systemctl restart mariadb. Verify with mysql -e "SHOW VARIABLES LIKE 'innodb_buffer_pool_size';".

Docker (frappe_docker)

Mount your override as a volume against the mariadb service: ./mariadb.cnf:/etc/mysql/conf.d/60-erpnext.cnf:ro in your compose file. This is exactly the same pattern Frappe uses for mariadb-frappe.cnf in the Raven CI setup.

Bench dev machine (macOS via Homebrew)

Path: $(brew --prefix)/etc/my.cnf.d/60-erpnext.cnf. Reload with brew services restart mariadb. Ignored on Apple Silicon dev boxes with plenty of RAM, but useful for reproducing prod-scale reports locally.

RHEL / Rocky / Alma

Path: /etc/my.cnf.d/60-erpnext.cnf. Reload with sudo systemctl restart mariadb. Same content, different directory.

The 60- prefix matters. Package-shipped configs typically use 50-server.cnf — a higher number ensures your file is read last and wins on conflict.

Benchmarking before you touch anything

This is the section nobody enjoys and everybody skips, so I will be blunt: do not restart your production MariaDB in pursuit of a speedup you cannot measure. Without a baseline you have no way to know whether the tuning helped, hurt, or did nothing at all. You will also have no way to defend the change to the finance director when the same report is slow next month.

Pick one of the three recipes below (bench, sysbench, or slow-log digest) and run it twice: once now, once after the restart. Publish only differences you measured on your own box.

Code recipeBenchmark Recipeslink

Three copy-paste ways to establish a real before/after baseline: bench execute, sysbench oltp_read_write, pt-query-digest.

Baseline recipes

Measure before you touch the config

Time a real ERPNext report end-to-end from the bench. Runs the same code path as the desk, no HTTP layer noise.

time_report.py
# Save as scripts/time_report.py in your bench directory.
# Run with:  bench --site erp.yourco.in execute scripts.time_report.run
import time, statistics, frappe

REPORT = "General Ledger"        # any Query/Script report
FILTERS = {
    "company": "Your Company",
    "from_date": "2025-04-01",
    "to_date":   "2026-03-31",
    "group_by":  "Group by Voucher (Consolidated)",
}
RUNS = 5

def run():
    from frappe.desk.query_report import run as run_report
    frappe.set_user("Administrator")

    # Warm the buffer pool with one throwaway pass.
    run_report(REPORT, filters=FILTERS)

    timings = []
    for i in range(RUNS):
        t0 = time.perf_counter()
        res = run_report(REPORT, filters=FILTERS)
        dt = (time.perf_counter() - t0) * 1000
        timings.append(dt)
        print(f"run {i+1}: {dt:8.1f} ms  ({len(res.get('result', []))} rows)")

    print("--- summary ---")
    print(f"p50 = {statistics.median(timings):.1f} ms")
    print(f"p95 = {sorted(timings)[int(len(timings)*0.95)-1]:.1f} ms")
    print(f"min = {min(timings):.1f} ms   max = {max(timings):.1f} ms")
Run each recipe twice: once against the current config to get your baseline, then again after applying the tuned my.cnf and restarting MariaDB. Never publish a "we made ERPNext 10× faster" number you didn't measure on your own box.

What order actually works

  1. Snapshot the box — full VPS snapshot, or at minimum a bench backup --with-files so you can roll back the entire site if a config typo takes the DB down.
  2. Run one benchmark recipe (I recommend the bench execute one — it exercises real ERPNext code paths).
  3. Drop the new my.cnf in place, do not restart yet.
  4. Restart MariaDB during a low-traffic window, then immediately bench restart on the Frappe side so its DB connections are re-established.
  5. Re-run the exact same benchmark. Save both outputs into ~/erpnext-tuning-{before,after}.txt. If the numbers didn't move, you have a real diagnostic problem, not a tuning problem.

Restarting MariaDB without breaking Frappe

A cold MariaDB restart with Frappe workers still running produces a cascade of MySQL server has gone away errors in the error log for a few seconds until each worker's pool reconnects. It is cosmetically ugly but harmless — Frappe uses connection retries for exactly this case. Still, the clean sequence is:

# 1. Pause the scheduler so no new background jobs kick off mid-restart
bench --site all set-maintenance-mode on

# 2. Full backup — DB + private files + public files
bench --site erp.yourco.in backup --with-files --compress

# 3. Drop the override file in
sudo cp 60-erpnext.cnf /etc/mysql/mariadb.conf.d/
sudo chmod 644 /etc/mysql/mariadb.conf.d/60-erpnext.cnf

# 4. Sanity-check the file parses BEFORE restarting
sudo mariadbd --help --verbose > /dev/null    # non-zero exit == config error

# 5. Restart MariaDB, then Frappe
sudo systemctl restart mariadb
bench restart

# 6. Verify the new values took effect
mysql -uroot -p -e "
  SHOW VARIABLES WHERE Variable_name IN (
    'innodb_buffer_pool_size',
    'innodb_log_file_size',
    'max_allowed_packet',
    'innodb_flush_log_at_trx_commit'
  );"

# 7. Bring the site back up
bench --site all set-maintenance-mode off

Two failure modes worth naming:

  • innodb_log_file_size change refuses to apply. On MariaDB versions before 10.5, changing this required shutting down cleanly and deleting the old ib_logfile* files. From 10.5 onward it is resized on next startup. If you are on an ancient distro release, either upgrade MariaDB first or omit the log-file change from the first pass.
  • The service refuses to start after edit. Almost always a typo (extra M, missing quote). sudo journalctl -u mariadb --no-pager | tail -50 shows the exact parse error. Roll back by deleting 60-erpnext.cnf, restart, restore from your snapshot only if that fails.

When tuning isn't enough

Tuning solves "your database has enough RAM to hold its working set and you weren't letting it." It does not solve "your database is fundamentally too big for one machine" or "your query pattern is O(rows × items) for a reason no amount of buffer pool will fix." The signals:

  • Buffer-pool hit ratio pinned at 99.9%+ and CPU still saturated. Check with SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read%' — if Innodb_buffer_pool_read_requests / (reads + read_requests) is ~1.0 and reports are still slow, you are CPU-bound on query execution, not I/O-bound on page fetching. More RAM will not help.
  • Slow queries dominated by a small handful of reports. pt-query-digest (the third recipe above) will surface these. Fix them at the ORM / index level, not the InnoDB level.
  • Write throughput is the bottleneck. InnoDB tuning helps reads far more than writes. If your bottleneck is bulk imports or heavy month-end postings, you are looking at a read replica, batching changes, or moving to horizontal partitioning.

In our experience the ordering is: tune first, index second, replica third, partition last. Skipping straight to a replica because "the DB is slow" is a common and expensive mistake — half the time tuning alone gives back 5–20× on read latency and the replica turns out to have been unnecessary.

FAQ

+How much of my server's RAM should the InnoDB buffer pool actually get?

The MySQL 8.0 Reference §15.8.3.1 says 50–75% on a dedicated DB host, 25–50% on a shared host. On a typical ERPNext VPS where MariaDB shares the machine with Frappe workers, Redis, nginx and supervisor, 60% is a sensible starting point. The calculator on this page will cap it at 70% and account for headroom against your DB size.

+My ERPNext DB is bigger than my server's RAM. Is tuning pointless?

No — you just cannot hold the whole DB in memory. The goal shifts from "keep everything hot" to "keep the working set hot." For most ERPNext workloads the working set is a small fraction of the whole (a few DocTypes, the last few months of GL entries). A buffer pool sized to 70% of RAM will usually still hold your working set. What tuning cannot fix is a genuinely cold-cache OLAP query over three years of data — that is a report-design problem.

+Is it safe to set innodb_flush_log_at_trx_commit = 2 on a production ERPNext?

For the vast majority of self-hosted ERPNext installs on a modern VPS with daily backups: yes. The trade is that a hard crash (kernel panic, host loss) can lose up to one second of committed transactions. In exchange you typically get a 2–3× write-throughput improvement. For a company running its own books this is a safe trade. For a bank, an exchange, or anything where sub-second durability is contractually required, stay on = 1.

+Do I need to change anything in Frappe's site_config.json to match this?

No. The Frappe layer is agnostic to MariaDB server-side tuning — connection pooling is handled per-worker by pymysql and the changes here happen entirely inside mariadbd. The only Frappe-side setting worth reviewing alongside this is background_workers in common_site_config.json, which governs how many concurrent DB connections your background jobs will spawn.

+Why 64M for max_allowed_packet specifically?

64M is the value the canonical discuss.frappe.io thread on ERPNext slow performance converges on, and it matches the size of the largest single-query payloads we see in practice (large report exports, bulk item imports, frappe.utils.file_manager operations on attachments). Below 16M the default, some background jobs crash with MySQL server has gone away mid-payload. Above 64M is fine but rarely necessary.

+I already restarted MariaDB and it's not any faster. What did I miss?

Three usual suspects, in order of frequency. First: your override file did not actually load — verify with SHOW VARIABLES LIKE 'innodb_buffer_pool_size' and confirm it matches the value you set. Second: the buffer pool is still cold — the first few minutes after a restart every query pays for warming it. Wait 10 minutes of real traffic, then re-benchmark. Third: your bottleneck was never the DB. Check htop while a slow report runs; if the Frappe worker process is at 100% CPU and MariaDB is idle, the fix is in the report code, not my.cnf.

+Does this apply to ERPNext v14/v15 or just v16?

The MariaDB tuning is identical across all Frappe major versions from v13 onwards, because it lives entirely below the ORM. The suggested values in this post were verified against a Frappe v16.28.0 checkout, but the underlying Frappe discuss thread with the canonical values dates back to v13/v14 and the guidance has not changed.

+Is switching from MariaDB to PostgreSQL a better answer?

For most ERPNext users, no. Frappe supports PostgreSQL but the MariaDB path is where the vast majority of the ecosystem's testing, index tuning, and community fixes live. A 10 GB ERPNext on a properly-tuned MariaDB will almost always beat the same instance on a stock PostgreSQL. Switch engines only if you have a specific reason (existing DBA expertise, replication topology, extensions) — not because you read a benchmark blog post.

Closing

Self-hosted ERPNext gets a reputation for being slow that is, three years out of four, entirely undeserved. The application layer is fine. The database layer is running with the same tuning as a laptop dev instance because the installer never touched it.

Compute your values with the calculator at the top, benchmark honestly against your own workload, restart in a maintenance window with a backup in hand, and re-benchmark. The delta will make you feel like you upgraded the hardware — because in the way that matters to InnoDB, you did.

Self-hosted ERPNext still crawling after the tuning pass?

We run a fixed-scope performance review for self-hosted Frappe/ERPNext instances — my.cnf tuning, slow-log digest, index review, and a written report with before/after numbers. Typical outcome: 5–20× on the reports your users complain about most.

Free · By email

Get practical ERPNext & automation guides

New implementation guides, cost breakdowns and open-source tips for Indian businesses — occasionally, straight to your inbox. No spam.

M

Written by

Manoj

Founder of Mith Tech, an open-source ERP & automation studio. Hands-on ERPNext/Frappe implementation across multi-branch, multi-warehouse Indian operations — GST/TDS/PT compliance, branch-level permissions, and custom Frappe apps that give management real-time visibility.

Keep reading

See what this looks like for your business

Book a free 30-minute audit. We'll map your workflows, find where time and money leak, and design an open-source stack you actually own — no per-user licence fees.

Book a consultation
0 0
Published on 24 July 2026

Manoj

Comments

No comments yet. Start a new discussion.

Ctrl+Enter to add comment