DEV Community

Cover image for 🗄️DB Performance 101: A Practical Deep Dive into Backend Database Optimization⚡
Arijit Ghosh
Arijit Ghosh

Posted on

🗄️DB Performance 101: A Practical Deep Dive into Backend Database Optimization⚡

Introduction

If you’ve ever watched your backend performance graph nosedive as production traffic rises, you already know the culprit often lurks below the API layer — in the database.

A well-designed backend can still stall if every request waits on slow queries, exhausted connections, or bloated indexes. While scaling compute resources might buy time, true backend optimization starts at the data layer.

This post focuses entirely on database-centric performance techniques — the stuff that really moves the needle when your system is in production. We’ll cover practical approaches ranging from connection pooling to sharding, with comparisons across PostgreSQL, MySQL, and MongoDB where relevant.


1. The Hidden Cost of Connections: Why Pooling Matters

Every database query starts with a handshake — establishing a TCP socket, authentication, and resource allocation. Opening and closing these connections per request is like starting your car engine every time you need to move a few meters.

The Fix: Connection Pooling

A connection pool keeps a set of ready-to-use connections in memory.
Instead of creating a new one for every query, your backend borrows an idle connection, executes, and returns it to the pool.

Example in Python (using psycopg2.pool)

import psycopg2
from psycopg2 import pool

# Initialize the connection pool
db_pool = pool.SimpleConnectionPool(
    minconn=5,
    maxconn=20,
    user='db_user',
    password='secret',
    host='localhost',
    port=5432,
    database='production_db'
)

# Borrow a connection
conn = db_pool.getconn()
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM orders;")
print(cursor.fetchone())

# Return connection
cursor.close()
db_pool.putconn(conn)
Enter fullscreen mode Exit fullscreen mode

Production-level pooling

For PostgreSQL, PgBouncer is the gold standard.
It acts as a lightweight proxy that manages thousands of connections efficiently.
Compared to the app-level pool, PgBouncer reduces memory overhead on the database and improves stability under spikes.

For MySQL, ProxySQL plays a similar role.


2. Tuning Your Pool: The Balancing Act

A poorly tuned connection pool can be worse than no pool at all. Too few connections cause queuing delays; too many exhaust the database server’s memory.

Key Parameters

Parameter Description Example Value
max_connections Max number of active connections 20–50 for most web apps
min_connections Keep a baseline ready 5–10
connection_lifetime Recycle connections periodically 30–60 minutes
idle_timeout Close idle connections 300 seconds

HikariCP-style fine-tuning logic

If you’re using SQLAlchemy, connection pooling is built in. Here’s a tuned setup:

from sqlalchemy import create_engine

engine = create_engine(
    "postgresql+psycopg2://db_user:secret@localhost/production_db",
    pool_size=20,
    max_overflow=5,
    pool_timeout=30,
    pool_recycle=1800
)
Enter fullscreen mode Exit fullscreen mode

Why this matters

  • PostgreSQL defaults (max_connections=100) can become a bottleneck with large pools from multiple app servers.
  • Monitor metrics like “active vs idle” connections and pool wait times.
  • Use PgBouncer’s transaction pooling mode for short-lived queries; session mode for transactions.

3. Indexing: Your Database’s Shortcut System

Indexes are your database’s version of a book’s index — you don’t read every page to find a word.

When to Index

  • Columns used in WHERE, JOIN, ORDER BY, or GROUP BY.
  • Foreign keys and high-selectivity columns (many unique values).

PostgreSQL Example

CREATE INDEX idx_orders_customer_id ON orders(customer_id);
Enter fullscreen mode Exit fullscreen mode

Composite Index

CREATE INDEX idx_orders_customer_date ON orders(customer_id, created_at);
Enter fullscreen mode Exit fullscreen mode

In Python (with SQLAlchemy)

from sqlalchemy import Column, Integer, Index, DateTime, ForeignKey
from sqlalchemy.orm import declarative_base

Base = declarative_base()

class Order(Base):
    __tablename__ = 'orders'
    id = Column(Integer, primary_key=True)
    customer_id = Column(Integer, ForeignKey('customers.id'))
    created_at = Column(DateTime)

    __table_args__ = (
        Index('idx_customer_date', 'customer_id', 'created_at'),
    )
Enter fullscreen mode Exit fullscreen mode

Be Careful

Every index speeds up reads but slows down writes (INSERT, UPDATE, DELETE).

  • Drop unused or redundant indexes.
  • Periodically run ANALYZE to update statistics.
  • Avoid indexing columns with low selectivity (like boolean flags).

MongoDB Comparison

MongoDB also uses B-tree indexes, but supports compound and text indexes with more flexibility. Use .explain("executionStats") to analyze query performance similarly to EXPLAIN ANALYZE in SQL.


4. ORM Queries: Convenience vs. Performance

ORMs are fantastic productivity boosters — until they silently generate 100 queries where one would do.

Example: The N+1 Query Problem

# Bad
orders = session.query(Order).all()
for order in orders:
    print(order.customer.name)  # Triggers a query per order
Enter fullscreen mode Exit fullscreen mode

Fix: Eager Loading

from sqlalchemy.orm import joinedload

orders = session.query(Order).options(joinedload(Order.customer)).all()
Enter fullscreen mode Exit fullscreen mode

Lazy Loading vs. Eager Loading

Mode Description When to Use
Lazy Load related data only when accessed For rarely accessed relationships
Eager Fetch related data in one go (via JOIN) When relations are always needed
Batch / Subquery Loading Fetch related data in batches When you have many children per parent

MongoDB Analogy

MongoDB avoids joins, but you pay the cost in client-side aggregation. Embedding documents can mimic eager loading, while references behave more like lazy loading.


5. Pagination Done Right

The classic OFFSET + LIMIT approach works fine until you reach tens of millions of rows.

Why OFFSET Fails

OFFSET 1000000 makes the database scan and discard a million rows before returning the next 20.

Better: Keyset Pagination

SELECT * FROM orders
WHERE id > 1050
ORDER BY id
LIMIT 20;
Enter fullscreen mode Exit fullscreen mode

Python Example

def fetch_next_page(last_id):
    query = "SELECT * FROM orders WHERE id > %s ORDER BY id LIMIT 20;"
    with engine.connect() as conn:
        result = conn.execute(query, (last_id,))
        return result.fetchall()
Enter fullscreen mode Exit fullscreen mode

When to Use OFFSET

Use only for small datasets or admin dashboards. For user-facing pagination, keyset pagination ensures consistent performance.


6. Stop Using SELECT * (Your Future Self Will Thank You)

🧑‍💻 “SELECT * is like ordering the entire menu when all you wanted was a sandwich.”

It’s easy, it works… until it doesn’t.

Fetching all columns might seem harmless during development, but in production it’s a silent performance killer:

  • It transfers more data than needed, increasing network latency.
  • It prevents the query planner from using covering indexes efficiently.
  • It wastes CPU and I/O, especially if the table contains large JSON, TEXT, or BLOB columns.

But here’s the kicker 👇
When your schema changes — say a new column is added, renamed, or reordered — SELECT * can break backend logic that depends on column order or specific field mappings.
ORMs and serializers that expect certain columns in a fixed order might suddenly throw runtime errors or return unexpected data.

Bad

SELECT * FROM customers;
Enter fullscreen mode Exit fullscreen mode

Good

SELECT id, name, email FROM customers;
Enter fullscreen mode Exit fullscreen mode

In Python

result = conn.execute("SELECT id, name, email FROM customers;")
for row in result:
    print(row.id, row.name, row.email)
Enter fullscreen mode Exit fullscreen mode

By explicitly selecting columns, you:

  • Reduce query time and network overhead.
  • Make your backend resilient to schema changes.
  • Keep your APIs stable and predictable even as your database evolves.

So next time you reach for SELECT *, remember:
It’s fine for a quick debug — but deadly in production. 🧠


7. When to Denormalize

Normalization reduces redundancy — great for integrity, but not always for speed.
In read-heavy workloads, excessive JOINs can kill performance.

Example: Before

SELECT o.id, c.name, p.title
FROM orders o
JOIN customers c ON o.customer_id = c.id
JOIN products p ON o.product_id = p.id;
Enter fullscreen mode Exit fullscreen mode

After Denormalization

Add redundant columns (e.g., customer_name, product_title) to orders.

ALTER TABLE orders ADD COLUMN customer_name TEXT;
Enter fullscreen mode Exit fullscreen mode

Then populate and maintain them with triggers or background jobs.

MongoDB Comparison

MongoDB inherently embraces denormalization — embedding related documents (e.g., a customer object inside each order) for faster reads at the cost of redundancy.


8. JOINs: Necessary Evil

JOINs are powerful but must be used with care.

Optimizing JOINs

  • Always index join keys.
  • Keep joins between tables of similar size when possible.
  • Avoid unnecessary LEFT JOINs (use INNER JOIN if possible).
  • For multi-table joins, check the join order via EXPLAIN.

Example

EXPLAIN ANALYZE
SELECT o.id, c.name
FROM orders o
JOIN customers c ON o.customer_id = c.id;
Enter fullscreen mode Exit fullscreen mode

PostgreSQL shows whether it used a Nested Loop, Hash Join, or Merge Join — each has different performance trade-offs.


9. Database Maintenance: The Forgotten Hero

Even perfectly tuned databases degrade over time.
Old rows, dead tuples, and stale stats accumulate — slowing everything down.

Routine Tasks

Task Description
VACUUM Reclaims space from deleted/updated rows
ANALYZE Updates query planner stats
REINDEX Rebuilds bloated indexes
Partitioning Splits large tables (e.g., by date)
Archiving Moves old data out of hot tables

Example

VACUUM (VERBOSE, ANALYZE);
Enter fullscreen mode Exit fullscreen mode

Or automate:

psql -d production_db -c "VACUUM ANALYZE;"
Enter fullscreen mode Exit fullscreen mode

Python Maintenance Job

import schedule
import time
from sqlalchemy import text

def vacuum_analyze():
    with engine.begin() as conn:
        conn.execute(text("VACUUM ANALYZE;"))
    print("Maintenance complete.")

schedule.every().day.at("03:00").do(vacuum_analyze)

while True:
    schedule.run_pending()
    time.sleep(60)
Enter fullscreen mode Exit fullscreen mode

10. Log and Analyze Slow Queries

You can’t fix what you don’t measure.

Enable Slow Query Logging (PostgreSQL)

In postgresql.conf:

log_min_duration_statement = 500  # log queries slower than 500ms
Enter fullscreen mode Exit fullscreen mode

View in logs or monitoring systems like pgBadger, Datadog, or New Relic.

Example Query Analysis

EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE created_at > NOW() - INTERVAL '7 days';
Enter fullscreen mode Exit fullscreen mode

You’ll see actual vs estimated cost, number of rows, and index usage.

MySQL has the slow_query_log; MongoDB exposes db.setProfilingLevel(1) for similar profiling.


11. Replication: Scaling Reads and Ensuring Redundancy

Replication copies data from a primary (write) node to one or more replicas (read-only).

Benefits

  • Read scaling — distribute queries across replicas.
  • High availability — failover in case of node failure.
  • Offload backups and analytics — run them on replicas.

PostgreSQL Setup (simplified)

# On primary
wal_level = replica
max_wal_senders = 10

# On standby
primary_conninfo = 'host=primary-db user=replicator password=secret'
Enter fullscreen mode Exit fullscreen mode

In Python (Routing Reads)

import random

READ_REPLICAS = ["replica1.db", "replica2.db"]

def get_read_connection():
    host = random.choice(READ_REPLICAS)
    return psycopg2.connect(host=host, user='app', password='secret', dbname='production_db')
Enter fullscreen mode Exit fullscreen mode

Be aware of replication lag — reads from replicas may be slightly stale.

MongoDB Comparison

MongoDB’s replica sets natively handle this with automatic election and read preference modes (primaryPreferred, secondaryPreferred).


12. Sharding: When One Database Isn’t Enough

If replication scales reads, sharding scales both reads and writes by splitting data horizontally.

Imagine dividing your customers by region or ID range, each on a different shard.

Approach

  1. Choose a shard key (e.g., user_id, region).
  2. Route queries based on shard key.
  3. Avoid cross-shard joins and transactions.

Illustration (textual)

+-----------+       +------------+
| Shard 1   |       | Shard 2    |
| User 1-50 |       | User 51-100|
+-----------+       +------------+
        \                 /
         \               /
          +---- App ----+
Enter fullscreen mode Exit fullscreen mode

Python Example

def get_shard_connection(user_id):
    shard_id = user_id % 2
    db_host = f"shard{shard_id}.db"
    return psycopg2.connect(host=db_host, dbname="production_db", user="app", password="secret")
Enter fullscreen mode Exit fullscreen mode

Sharding requires routing logic and consistent hashing strategies — consider libraries like Citus (for PostgreSQL) or managed sharded solutions (e.g., MongoDB Atlas, CockroachDB).


13. Profiling and Observability

PostgreSQL provides deep introspection tools:

  • pg_stat_statements – aggregate query stats.
  • EXPLAIN ANALYZE – visualize query execution.
  • auto_explain – automatically logs slow queries.
  • Extensions like pg_stat_activity show active sessions.

Python Integration

from sqlalchemy import text

with engine.connect() as conn:
    result = conn.execute(text("SELECT query, total_time FROM pg_stat_statements ORDER BY total_time DESC LIMIT 10;"))
    for row in result:
        print(row)
Enter fullscreen mode Exit fullscreen mode

Pair this with Grafana + Prometheus to visualize query times, buffer usage, and cache hit ratios.


Conclusion: Building a Performance-First Mindset

Backend performance is not about one magic tweak — it’s about discipline and visibility.

Start with fundamentals:
âś… Use connection pooling
âś… Index smartly
âś… Monitor ORM queries
âś… Denormalize carefully
âś… Maintain your DB regularly
âś… Scale with replication and sharding

Over time, these practices turn your backend from a fragile system into a resilient, high-performing service.

Database optimization isn’t just about making queries faster — it’s about designing systems that stay fast as they grow.


Top comments (3)

Collapse
 
sabujghosh profile image
Sabuj Ghosh

Very Informative. Will be using them for sure.

Collapse
 
ari-ghosh profile image
Arijit Ghosh

Thanks

Collapse
 
iamssr profile image
Swapnodip Singha Roy

Informative indeed