keepcalculator

06 Numbers and Powers

PostgreSQL Webhook Audit Logs: Partitioning and Retention

Card No. 06·56092 · Updated · Standard formula, shown below

PostgreSQL can store billions of webhook audit records, but the design must make the data’s growth, query patterns, and retention period explicit. A practical approach is to partition by time, index only the queries you need, compress older partitions when the PostgreSQL version and workload support it, and delete expired data by dropping whole partitions.

Plan for volume and query patterns

“Billions of rows” is not, by itself, a reason PostgreSQL cannot work. The important variables are how quickly records arrive, how many must remain online, how wide each row is, and what users need to search. A billion narrow records and a billion records containing large request and response bodies have very different storage, backup, and maintenance costs.

Start by documenting the workload:

  • Estimate average and peak events per second, then calculate daily and yearly row counts.
  • Measure representative row sizes, including indexes and any stored payloads.
  • List the common filters: tenant, event ID, delivery status, endpoint, and time range.
  • Set the online retention period and identify which records, if any, must be kept longer in another system.

For example, 1,000 events per second produces 86.4 million events per day. At that scale, retention of 30 days means roughly 2.6 billion rows before accounting for retries or other event types. Capacity planning should include indexes, free space during maintenance, replicas, backups, and growth headroom—not just the table’s raw data.

Partition by time

Declarative range partitioning is a common fit for append-heavy audit logs. A parent table defines the schema, while child partitions cover non-overlapping time ranges, such as one day or one month. PostgreSQL routes inserts to the matching partition when the partition key is included.

Use the event’s recorded creation time as the partition key when retention and queries are primarily time-based. Queries should include a condition on that key, such as a bounded timestamp range, so partition pruning can exclude irrelevant partitions. Pruning reduces the amount of data PostgreSQL needs to inspect; it does not make a query selective within the partitions it must read.

Choose partition size by balancing management overhead and query scope. Daily partitions make expiration precise and keep each partition smaller, but create many relations. Monthly partitions are easier to manage but make dropping data less precise and may leave more data to scan. The right interval depends on the write rate, retention granularity, and observed maintenance and query performance. Test with realistic data rather than assuming a fixed row count per partition will suit every system.

Create partitions ahead of time and monitor for missing ranges. If an insert has no matching partition and no default partition is configured, it can fail. A default partition can prevent that failure, but it should trigger an alert and be emptied or reorganized; otherwise it can quietly collect data and complicate future partition creation.

Choose indexes for actual searches

Indexes make selected lookups faster, but each index consumes disk and adds work to inserts, updates, vacuuming, and backups. Avoid indexing every column “just in case.” For time-bounded searches, a B-tree index can support equality filters and ordered timestamp scans. A multicolumn index is most useful when its leading columns match common query conditions; column order matters.

For instance, if operators usually look up a tenant’s events newest-first, a tenant-and-time B-tree may fit better than separate indexes, but the choice depends on the SQL and data distribution. PostgreSQL can create indexes on a partitioned parent, with corresponding indexes on its partitions; operational procedures should ensure new partitions receive the required indexes.

Consider these trade-offs:

  • Primary key: Choose one that reflects uniqueness and does not create unnecessary index cost. A time-partitioned table generally needs a unique constraint to include the partition key.
  • JSON payloads: Store flexible payloads as JSONB only when querying their contents is valuable. A broad GIN index can be large and expensive to maintain; targeted expression indexes may be more suitable.
  • BRIN: For very large, physically time-correlated data, a BRIN index may offer a compact way to narrow time scans. It is lossy and can require rechecking rows, so it is not a general replacement for B-tree indexes.
  • Unindexed fields: Keep fields that are rarely searched out of indexes, and consider keeping large payloads separate from frequently read metadata.

Store and compress payloads carefully

PostgreSQL already applies TOAST storage to many oversized variable-length values, which can store large values out of line and use compression where applicable. That behavior is not equivalent to a dedicated, tunable compression system for an entire audit table. PostgreSQL’s built-in table access methods and compression options vary by release; verify the capabilities of the exact version in use before designing around them.

Where supported, a column’s compression setting can affect eligible TOAST values, but changing it does not necessarily rewrite existing rows immediately. Rewriting or moving data may be needed to apply a new storage choice to old values, and that work has operational costs. Benchmark real webhook payloads: already-compressed data, such as many binary formats, may save little, while text or JSON can behave differently.

For infrequently accessed history, an extension such as a columnar storage option may be worth evaluating, but it changes operational assumptions and query behavior. Another common design is to keep searchable metadata in PostgreSQL and put bulky request or response bodies in object storage, retaining a reference and checksum in the database. This reduces database table and backup growth, but introduces another storage system and requires its own access controls, durability, lifecycle, and recovery plan.

Retention, vacuum, and operations

Time partitions make retention easier because expired data can usually be removed by detaching or dropping an entire old partition rather than issuing a huge row-by-row DELETE. Dropping a partition is fast compared with deleting millions of rows, which would generate substantial WAL, leave dead tuples to vacuum, and potentially create replication lag. Follow the PostgreSQL version’s documented partition-management procedure and test locking and dependency effects before production use.

Set retention boundaries precisely. If policy says to retain 90 days, define which timestamp is authoritative and when a partition is eligible to expire. Partition intervals can mean records are retained slightly longer than the nominal boundary; use smaller intervals or a separate process if tighter precision is necessary. Do not assume partition deletion erases copies in backups, replicas, exports, or external object storage.

Monitor table and index size, partition count, insert latency, WAL volume, replication delay, autovacuum activity, disk headroom, and backup duration. Inserts are typically append-heavy, but updates to delivery status can create dead tuples and make vacuuming important. Keep statistics current, use representative query plans, and rehearse restoring data; a fast write path is not useful if recovery cannot meet operational needs.

Common design mistakes

  • Partitioning without including the partition key in routine queries, leaving PostgreSQL to scan many partitions.
  • Creating too many indexes, increasing write cost and storage without improving measured queries.
  • Keeping every raw body indefinitely in the primary database despite a short operational search window.
  • Using a default partition as an unmonitored catch-all for missing partition creation.
  • Assuming compression removes the need for capacity planning, backups, or retention.
  • Testing only a small table, then discovering at production scale that index builds, vacuuming, backups, or restores take too long.

Frequently Asked Questions

Can PostgreSQL handle billions of audit-log rows?
It can, provided the schema, hardware, partitioning, indexes, maintenance, and query patterns are designed and tested for the workload. Row count alone does not determine feasibility; payload size, write rate, retention, and latency requirements matter.

Should webhook logs be partitioned by day or month?
Use the interval that balances partition-management overhead with query and retention needs. Daily partitions can make expiration more granular; monthly partitions reduce the number of relations. Benchmark both against realistic volumes and queries.

Does partitioning automatically make queries faster?
No. It helps when PostgreSQL can prune partitions using conditions on the partition key. Queries spanning most of the retained history may still read substantial data, and indexes remain important for filtering within each partition.

Is it better to store webhook payloads in PostgreSQL or object storage?
Keep payloads in PostgreSQL when transactional access and querying justify the storage cost. Consider object storage for large, infrequently read bodies while keeping searchable metadata and a durable reference in PostgreSQL. The choice depends on access, recovery, security, and lifecycle requirements.

What is the safest way to delete expired logs?
With time-based partitions, dropping or detaching fully expired partitions is usually more efficient than deleting rows individually. Confirm the exact cutoff, backups and replicas behavior, dependencies, and operational procedure before enabling automated removal.

Related

Method: the standard formula for this calculation, rounded as each result states. See how the calculators work or report an error.

More cards in this drawer

The whole Numbers and Powers drawer →