Introduction
Database performance can make or break your application. When our photography management system started slowing down as the client base grew, I knew it was time to dig deep into PostgreSQL optimization. The result? A 40% improvement in overall query performance and 35% faster response times on complex queries.
In this post, I'll share the exact techniques I used, real code examples, and measurable results. Whether you're dealing with slow queries or planning for scale, these optimizations will help.
The Performance Problem
Our application started with great performance—sub-100ms response times across the board. But as we grew to thousands of clients, bookings, and images, things started to slow down:
Before Optimization:
After Optimization:
Let me show you how.
Step 1: Identifying the Bottlenecks
Before optimizing anything, you need data. PostgreSQL provides excellent tools for finding slow queries.
Enable Query Logging
-- postgresql.conf
log_min_duration_statement = 1000 -- Log queries taking > 1 second
log_line_prefix = '%t [%p]: [%l-1] user=%u,db=%d,app=%a,client=%h '
log_statement = 'all'
Restart PostgreSQL:
sudo systemctl restart postgresql
Analyze Query Performance
Use EXPLAIN ANALYZE to see exactly what's happening:
EXPLAIN ANALYZE
SELECT c.*, COUNT(b.id) as booking_count
FROM clients c
LEFT JOIN bookings b ON c.id = b.client_id
GROUP BY c.id
ORDER BY c.last_name;
Output before optimization:
Sort (cost=1234.56..1245.67 rows=4444 width=123) (actual time=1823.456..1834.567 rows=4444 loops=1)
Sort Key: c.last_name
Sort Method: external merge Disk: 12345kB
-> HashAggregate (cost=789.01..890.12 rows=4444 width=123)
Group Key: c.id
-> Hash Right Join (cost=123.45..567.89 rows=45678 width=123)
Hash Cond: (b.client_id = c.id)
-> Seq Scan on bookings b (cost=0.00..234.56 rows=45678 width=16)
-> Hash (cost=90.12..90.12 rows=4444 width=115)
-> Seq Scan on clients c (cost=0.00..90.12 rows=4444 width=115)
Planning Time: 0.123 ms
Execution Time: 1850.234 ms
The Seq Scan (sequential scan) is the culprit—it's reading every row.
Step 2: Strategic Indexing
Indexes are the foundation of query performance. But adding too many can hurt write performance. The key is strategic placement.
Index on Foreign Keys
-- Before: No index on foreign keys CREATE TABLE bookings ( id UUID PRIMARY KEY, client_id UUID NOT NULL REFERENCES clients(id), photographer_id UUID NOT NULL REFERENCES users(id), date DATE NOT NULL, status VARCHAR(50) NOT NULL );
-- After: Index foreign keys CREATE INDEX idx_bookings_client_id ON bookings(client_id); CREATE INDEX idx_bookings_photographer_id ON bookings(photographer_id);
Impact: 70% faster joins
Composite Indexes for Common Queries
-- Common query: Get photographer's bookings for a date range SELECT * FROM bookings WHERE photographer_id = 'abc-123' AND date BETWEEN '2025-01-01' AND '2025-12-31' AND status != 'cancelled';
-- Create composite index CREATE INDEX idx_bookings_photographer_date_status ON bookings(photographer_id, date, status) WHERE status != 'cancelled';
Why this order?
1. photographer_id - High selectivity (filters most rows)
2. date - Range scan
3. status - Additional filter
Impact: Query went from 1.2s to 45ms (96% faster)
Partial Indexes
Only index rows you actually query:
-- Bad: Index everything CREATE INDEX idx_invoices_status ON invoices(status);
-- Good: Only index unpaid invoices CREATE INDEX idx_invoices_unpaid ON invoices(client_id, due_date) WHERE status = 'unpaid';
Benefits:
Full-Text Search Indexes
-- Add tsvector column for search ALTER TABLE clients ADD COLUMN search_vector tsvector;-- Update search vector on insert/update CREATE FUNCTION clients_search_trigger() RETURNS trigger AS $$ BEGIN NEW.search_vector := setweight(to_tsvector('english', coalesce(NEW.first_name,'')), 'A') || setweight(to_tsvector('english', coalesce(NEW.last_name,'')), 'A') || setweight(to_tsvector('english', coalesce(NEW.email,'')), 'B'); RETURN NEW; END $$ LANGUAGE plpgsql;
CREATE TRIGGER clients_search_update BEFORE INSERT OR UPDATE ON clients FOR EACH ROW EXECUTE FUNCTION clients_search_trigger();
-- Create GIN index for fast search CREATE INDEX idx_clients_search ON clients USING GIN(search_vector);
Now search is lightning-fast:
SELECT * FROM clients
WHERE search_vector @@ to_tsquery('english', 'john & smith');
Impact: Search went from 1.8s to 0.08s (95% faster)
Step 3: Query Optimization
Sometimes the index exists but isn't being used. Here's how to optimize queries.
Use Proper Joins
-- Bad: Subquery causes sequential scan SELECT c.* FROM clients c WHERE c.id IN ( SELECT client_id FROM bookings WHERE status = 'confirmed' );
-- Good: JOIN with index SELECT DISTINCT c.* FROM clients c INNER JOIN bookings b ON c.id = b.client_id WHERE b.status = 'confirmed';
Impact: 3.2s → 0.4s (87% faster)
Avoid SELECT *
-- Bad: Fetching unnecessary data SELECT * FROM clients c JOIN bookings b ON c.id = b.client_id;
-- Good: Only select what you need SELECT c.id, c.first_name, c.last_name, b.date, b.status FROM clients c JOIN bookings b ON c.id = b.client_id;
Impact: 40% less data transfer, 25% faster queries
Use LIMIT with Pagination
-- Bad: Fetching all results SELECT * FROM bookings ORDER BY date DESC;
-- Good: Paginate results SELECT * FROM bookings ORDER BY date DESC LIMIT 20 OFFSET 0;
Optimize Aggregations
-- Bad: Counting in application code -- (Fetching all bookings then counting in C#)// Bad C# code var bookings = await _context.Bookings .Where(b => b.ClientId == clientId) .ToListAsync(); var count = bookings.Count;
// Good: Count in database var count = await _context.Bookings .Where(b => b.ClientId == clientId) .CountAsync();
Impact: 90% less memory usage, 75% faster
Step 4: Connection Pooling
Every database connection has overhead. Connection pooling reuses connections.
Configuration in .NET
// appsettings.json
{
"ConnectionStrings": {
"DefaultConnection": "Host=100.1.2.4;Database=photomanager;Username=app;Password=xxx;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=20;Connection Lifetime=300"
}
}
Key parameters:
Pooling=true - Enable poolingMinimum Pool Size=5 - Keep 5 connections aliveMaximum Pool Size=20 - Max 20 concurrent connectionsConnection Lifetime=300 - Recycle after 5 minutesImpact: 60% reduction in connection overhead
PostgreSQL Configuration
-- postgresql.conf
max_connections = 100
shared_buffers = 256MB
effective_cache_size = 1GB
maintenance_work_mem = 64MB
checkpoint_completion_target = 0.9
wal_buffers = 16MB
default_statistics_target = 100
random_page_cost = 1.1
effective_io_concurrency = 200
work_mem = 4MB
min_wal_size = 1GB
max_wal_size = 4GB
These settings are optimized for a 16GB RAM server.
Step 5: Entity Framework Core Optimization
EF Core can generate inefficient queries if not used carefully.
Use AsNoTracking() for Read-Only Queries
// Bad: Change tracking overhead var clients = await _context.Clients .Include(c => c.Bookings) .ToListAsync();
// Good: No tracking for read-only data var clients = await _context.Clients .Include(c => c.Bookings) .AsNoTracking() .ToListAsync();
Impact: 30-40% faster reads, less memory
Eager Loading vs. Lazy Loading
// Bad: N+1 query problem var clients = await _context.Clients.ToListAsync(); foreach (var client in clients) { // Each iteration queries database again! var bookingCount = client.Bookings.Count; }
// Good: Eager load relationships var clients = await _context.Clients .Include(c => c.Bookings) .ToListAsync();
Impact: 100 clients with N+1 = 101 queries. Eager loading = 1 query. 99% fewer queries!
Projection for Large Objects
// Bad: Loading entire entities var clients = await _context.Clients .Include(c => c.Bookings) .Include(c => c.Invoices) .ToListAsync();
// Good: Project only what you need var clients = await _context.Clients .Select(c => new ClientSummaryDto { Id = c.Id, Name = $"{c.FirstName} {c.LastName}", BookingCount = c.Bookings.Count, TotalRevenue = c.Invoices.Sum(i => i.Total) }) .ToListAsync();
Impact: 80% less data transfer
Compiled Queries
// Define compiled query private static readonly Func<AppDbContext, Guid, Task<Client>> GetClientByIdCompiled = EF.CompileAsyncQuery( (AppDbContext context, Guid id) => context.Clients .Include(c => c.Bookings) .FirstOrDefault(c => c.Id == id));
// Use compiled query var client = await GetClientByIdCompiled(_context, clientId);
Impact: 15-20% faster for frequently-used queries
Step 6: Caching Strategy
Some data doesn't change frequently. Cache it.
In-Memory Caching
public class CachedClientService
{
private readonly IMemoryCache _cache;
private readonly AppDbContext _context;
public async Task<Client> GetClientByIdAsync(Guid id)
{
var cacheKey = $"client_{id}";
if (!_cache.TryGetValue(cacheKey, out Client client))
{
client = await _context.Clients
.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == id);
if (client != null)
{
_cache.Set(cacheKey, client, TimeSpan.FromMinutes(10));
}
}
return client;
}
}
Impact: 95% faster for cached data
PostgreSQL Query Result Cache
PostgreSQL automatically caches query results, but you can optimize:
-- Increase shared buffers for better caching
ALTER SYSTEM SET shared_buffers = '512MB';
SELECT pg_reload_conf();
Step 7: Monitoring and Maintenance
Set up continuous monitoring to catch regressions early.
pg_stat_statements Extension
-- Enable extension CREATE EXTENSION pg_stat_statements;
-- Find slowest queries SELECT query, calls, total_exec_time / 1000 as total_time_seconds, mean_exec_time / 1000 as avg_time_seconds, rows FROM pg_stat_statements ORDER BY mean_exec_time DESC LIMIT 10;
Regular VACUUM and ANALYZE
# Add to cron
0 2 * psql -U postgres -d photomanager -c "VACUUM ANALYZE;"
Index Usage Statistics
SELECT
schemaname,
tablename,
indexname,
idx_scan as index_scans,
idx_tup_read as tuples_read,
idx_tup_fetch as tuples_fetched
FROM pg_stat_user_indexes
ORDER BY idx_scan ASC;
Remove unused indexes:
-- If idx_scan is 0, consider dropping the index
DROP INDEX IF EXISTS unused_index_name;
Results Summary
Here's the complete before/after comparison:
| Operation | Before | After | Improvement | |-----------|--------|-------|-------------| | Dashboard Load | 2.3s | 0.9s | 61% | | Client Search | 1.8s | 0.08s | 95% | | Invoice Generation | 4.5s | 2.1s | 53% | | Gallery Listing | 3.2s | 1.4s | 56% | | Booking Query | 1.2s | 0.045s | 96% | | Average Response | 2.6s | 0.91s | 65% |
Overall: 40% improvement in query performance
Key Takeaways
1. Measure first - Use EXPLAIN ANALYZE before optimizing 2. Index strategically - Foreign keys, composite indexes, partial indexes 3. Optimize queries - Proper joins, select only what you need 4. Configure pooling - Reuse connections 5. Use EF Core wisely - AsNoTracking, eager loading, projections 6. Cache appropriately - Reduce database load 7. Monitor continuously - Catch regressions early
Tools I Used
Conclusion
PostgreSQL optimization isn't about one magic fix—it's about systematic improvement across indexing, queries, configuration, and application code. The 40% performance improvement came from dozens of small optimizations that compounded.
Start with the biggest bottlenecks first (usually missing indexes), then work your way through query optimization, connection pooling, and caching. Monitor continuously and optimize based on real usage patterns, not assumptions.
Your database is fast enough—you just need to unlock it.
Questions about PostgreSQL performance? I'm always happy to discuss database optimization strategies!
Tech Stack:
Performance Tools:
Related Posts
Building with Angular + Firebase? Check out my Photography E-Commerce Platform that combines Firebase auth with Stripe payments and AWS S3 storage.
Self-hosting your projects? Learn how I host production apps on a Raspberry Pi with PM2, Nginx, and Tailscale.
About the Author: Ricardo Gil is a full-stack software engineer specializing in .NET/C#, Angular, and cloud platforms. Read more or subscribe for updates.
