When PostgreSQL performance slows down, here is where to look first

PostgreSQL is built to perform. However, as workloads increase and systems evolve, even the most robust setups can begin to show signs of strain. Whether you are scaling a product or supporting enterprise SLAs, performance slowdowns tend to surface when you least want them to. If you are a technology leader overseeing a team of developers who manage PostgreSQL as part of a broader application stack, or you are responsible for uptime and customer satisfaction at scale, knowing where to look first can make all the difference.
Read More

How to Upgrade Major PostgreSQL Versions: A Practical Production Guide

PostgreSQL versions follow a well-defined five-year support lifecycle. Each major release receives bug fixes, security patches, and minor updates for five years from its initial release date. After that point, the version reaches end-of-life (EOL) and no longer receives official updates. Staying on an EOL version exposes your systems to security risks, potential compatibility issues, and missing performance improvements introduced in later releases. You can always check the current support status of PostgreSQL versions on the official PostgreSQL Versioning Policy page. Upgrading to the latest version ensures long-term stability, access to new features, and better support. Recently, I worked on upgrading a critical production PostgreSQL environment from version 11 to 15. Version 15 was chosen because the client’s application had only been tested up to that release. The system supported large batch workloads and live applications, so we had to be meticulous. While this article draws from that specific project, the steps are broadly applicable to anyone planning a major PostgreSQL upgrade, especially when crossing several versions. This guide outlines a generalized, production-ready approach for performing major version upgrades using the pg_dump/pg_restore method.
Read More

Best Practices for TimescaleDB Massive Delete Operations

Welcome to the second part of our TimescaleDB best practices series! In the first part, we explored how to perform massive backfill operations efficiently, sharing techniques to optimize performance and avoid common pitfalls. If you haven’t had a chance to read the first part yet, you can check it out using this link In today's blog, we will discuss another crucial aspect of time-series data management: massive delete operations. As your data grows over time, older records often lose their relevance but continue to occupy valuable disk space, potentially increasing storage costs and might degrade the performance if not managed well.  Let's walk through some strategies to clean up or downsample aged data in TimescaleDB, helping you maintain a lean, efficient, and cost-effective database.
Read More

How to Safely Perform Backfill Operations in TimescaleDB

Backfilling data into a TimescaleDB hypertable in production can be very tricky, especially when automated processes like compression policies are involved. From past experience, we have seen that if backfill operations aren’t handled properly, they can interfere with these automated tasks, sometimes causing them to stop working altogether.  This blog covers a safer and more reliable approach to backfilling hypertables, along with best practices to prevent disruptions to compression and other background processes. What is a Backfill Operation? Backfilling means adding old or missing data into the database table after some time has already passed.  Imagine you are collecting temperature readings every hour, but your system was down for a day and didn’t save any data. Later, you get that missing data from the local storage of the device or cloud storage, and want to put it back in the right hypertable, which is called backfilling.  In TimescaleDB, this is common with time-series data, but it needs to be done carefully. That’s because TimescaleDB might already be doing things in the background, like compressing old data to save space. If we are not careful, backfilling can mess up these automatic tasks.
Read More

Improving PostgreSQL Performance with Partitioning

My recommended methodology for performance improvement of PostgreSQL starts with query optimization. The second step is architectural improvements, part of which is the partitioning of large tables. Partitioning in PostgreSQL is one of those advanced features that can be a powerful performance booster. If your PostgreSQL tables are becoming very large and sluggish, partitioning might be the cure. The Big Table Problem Large tables tend to grow uncontrollably, especially in OLTP or time-series workloads. As millions or billions of rows accumulate, you begin to notice: Slow queries due to full table scans or massive indexes. Heavy I/O usage, especially when indexes cannot fit in memory. Bloated memory during operations like sorting or joining. Increased maintenance cost, with longer VACUUM, ANALYZE, and REINDEX times. Hard-to-manage retention policies, as purging old rows becomes expensive. These problems are amplified in cloud-hosted databases, where every IOPS, GB, or CPU upgrade increases cost.
Read More

Finding Bottlenecks and Avoiding Over-Optimization via Explain Plans

Performance optimization in a production database is important, but trying to over-optimize can make things more complicated without real improvements. In this post, I’ll share two very basic EXPLAIN ANALYZE outputs from a production system. A user asked us to help optimize these queries. I've changed the table and column names for privacy. We will look at how to spot slow parts of a query, improve performance the right way, and avoid unnecessary tuning. Plan A: Identifying and Resolving a Bottleneck Execution Plan A (Before Optimization) Nested Loop (cost=1000.42..25607.38 rows=1 width=129) (actual time=78.521..90.445 rows=0 loops=1) -> Gather (cost=1000.00..25598.95 rows=1 width=65) (actual time=78.520..90.443 rows=0 loops=1) Workers Planned: 2 Workers Launched: 2 -> Parallel Seq Scan on <table_1> e (cost=0.00..24598.85 rows=1 width=65) (actual time=75.351..75.351 rows=0 loops=3) Filter: ((<column_1>) = '<date_value>'::date) AND ((<column_2>)::text = '<event_type>'::text) Rows Removed by Filter: <number_removed_rows> -> Index Scan using <index_name> on <table_2> a (cost=0.42..8.43 rows=1 width=41) (never executed) Index Cond: ((<column_3>)::text = (<column_4>)::text) Filter: ((<column_5>)::text = '<default_value>'::text) Planning Time: 0.466 ms Execution Time: 90.580 ms
Read More

SELECT FOR UPDATE – Reduce Contention and Avoid Deadlocks to Improve Performance in PostgreSQL

Relational databases are at the heart of countless applications around the world, from high-traffic e-commerce websites to enterprise resource planning (ERP) systems and financial services. Concurrency management—where multiple database transactions operate on the same data simultaneously—is critical to getting good performance and avoiding problems like deadlocks or data inconsistencies. When multiple transactions need to modify the same rows, ensuring data consistency can become tricky. A single wrong approach to locking can lead to suboptimal performance or even bring your application to a standstill as numerous transactions block one another. One tool in PostgreSQL’s arsenal to handle concurrency is SELECT FOR UPDATE. It allows you to lock specific rows before updating them, preventing other transactions from modifying those rows until your transaction completes. In this blog, we will dive deep into SELECT FOR UPDATE in PostgreSQL. We will explore how it helps in reducing contention, avoiding deadlocks, and ultimately boosting performance when dealing with highly concurrent applications.
Read More

When HASH partitioning works better than RANGE

I have always been a fan of RANGE partitioning using a date/time value in PostgreSQL. This isn't always possible, however, and I recently came across a scenario where a table had grown large enough that it had to be partitioned, and the only reasonable key to use was a UUID styled identifier. The goal of this post is to highlight when and why hashing your data across partitions in PostgreSQL might be a better approach. Range vs. Hash Partitioning in PostgreSQL Range Partitioning (A Quick Recap) Range partitioning in PostgreSQL uses boundary values that define slices of the data, often by date or numeric ranges. If you have a transactions table, you might create monthly partitions based on a transaction_date column. This is intuitive for time-series data because each partition holds rows from a specific date range. Advantages of Range Partitioning: Easy pruning for date-based queries. Straightforward approach to archiving old data: drop an entire partition for a past month, rather than issuing a massive DELETE. Pairs nicely with time-based ingestion pipelines, where every day or month gets its own partition. But as convenient as that is, there are cases where range partitioning runs into problems. Why Range Partitioning Can Fall Short Data Skew: If a huge portion of data lands in a single time interval—say, because of a traffic spike in the current month—that monthly partition might end up significantly larger than the others. Complex Backfills: Not everyone ingests data in an orderly, daily manner. Sometimes you need to re-ingest or correct data that spans multiple periods. Merging or splitting range partitions can get cumbersome. Non-Date Dimensions: Some tables aren’t naturally tied to a sequential numeric or date dimension. If your queries center on user IDs or device IDs, dividing by date might not solve your performance issues.
Read More

Important PostgreSQL Parameters: Understanding Their Importance and Recommended Values

Have you ever experienced your database slowing down as the amount of data increases? If so, one important factor to consider is tuning PostgreSQL parameters to match your specific workload and requirements.  PostgreSQL has many parameters because it is designed to be highly flexible and customizable to meet a wide range of use cases and workloads. Each parameter allows you to fine-tune different aspects of the database, such as memory management, query optimization, connection handling, and more. This flexibility helps database administrators to optimize performance based on hardware resources, workload requirements, and specific business needs. In this blog, I will cover some of the important PostgreSQL parameters, explain their role, and provide recommended values to help you fine-tune your database for better performance and scalability.  Memory-Related Parameters Memory-related parameters in PostgreSQL control how the database allocates and manages memory. Tuning these settings is important for improving query performance and preventing resource bottlenecks. Name: work_mem Description: Sets the maximum amount of memory used by internal operations like sorts and hashes before writing to disk. Increasing it can improve performance for complex queries Default: 4MB Recommended: Typically, setting work_mem to 1-2% of the total system's available memory is recommended, i.e., if the total system memory is 256 GB, assign 3 to 5 GB for work_mem. Note: This may lead to higher memory usage for operations that involve sorting. Name: shared_buffers Description: Determines the amount of memory allocated for caching database data. Default: 128MB Recommendation: Typically, setting shared_buffers to 25-40% of the total system memory is recommended, i.e., if the total system memory is 256 GB, assign 64-102 GB for shared_buffers. Name: maintenance_work_mem Description: Specifies the amount of memory used for maintenance operations like VACUUM, CREATE INDEX, and ALTER TABLE. Increasing it can speed up these operations. Default: 64MB Recommendation: it's recommended to set 5-10% of the total system memory, i.e., if the total system memory is 256 GB, assign 13 to 26 GB for maintenance_work_mem.
Read More

Leveraging autovacuum in PostgreSQL to optimize performance and reduce costs

Autovacuum is one of PostgreSQL's most powerful features, designed to maintain database health and optimize performance by automating routine maintenance tasks. However, improper configuration can lead to performance bottlenecks, excessive costs due to resource inefficiency, or uncontrolled table bloat. This blog explores what autovacuum is, its role in performance optimization and cost reduction, and best practices for configuring its parameters. What is Autovacuum? Autovacuum is a background process in PostgreSQL responsible for maintaining table health by performing two critical tasks: 1. Vacuuming - Removes dead tuples (rows that have been updated or deleted but are no longer visible). - Frees up space for reuse to prevent table bloat and reduce storage costs. 2. Analyzing - Updates table statistics used by the query planner to optimize execution plans, improving query performance. Without autovacuum, dead tuples can accumulate, leading to: - Table Bloat: Increased disk usage drives up storage costs and slows query performance. - Transaction ID Wraparound: A situation that forces the system to go into ‘safe mode’, blocking non-superuser transactions to protect data integrity. This can render the database unusable if not addressed, causing downtime and increased operational costs. By automating these tasks, autovacuum ensures consistent database performance and minimizes unnecessary costs.
Read More