Database Advanced

Troubleshooting PostgreSQL VACUUM FULL Table Lock Blocking Select Queries on macOS

Resolve PostgreSQL VACUUM FULL operations locking tables and halting read queries on macOS. Understand its impact and implement strategies for efficient database maintenance.

👨‍💻
Senior Systems Architect • Verified in Staging Labs

Resolve PostgreSQL VACUUM FULL operations locking tables and halting read queries on macOS. Understand its impact and implement strategies for efficient database maintenance.

Introduction

As a developer or system administrator working with PostgreSQL on a local macOS environment, you might occasionally encounter situations where your application's SELECT queries inexplicably hang or time out. A common, yet often misunderstood, culprit for this behavior is a VACUUM FULL operation locking a crucial table. While VACUUM FULL is a powerful command for reclaiming disk space, it does so by acquiring an ACCESS EXCLUSIVE lock, effectively halting all concurrent operations on the affected table—including simple read queries. This guide will walk you through diagnosing, resolving, and preventing such occurrences.

Symptom & Error Signature

When a VACUUM FULL operation holds an ACCESS EXCLUSIVE lock on a table, any subsequent SELECT, INSERT, UPDATE, or DELETE queries attempting to access that table will be queued, waiting for the lock to be released. On your application's side, this manifests as:

  • Hanging Queries: Your application's queries appear to run indefinitely without returning results.
  • Client-Side Timeouts: If your application or database driver has a statement timeout configured, you might see errors like:
    PG::QueryCanceled: ERROR:  cancelling statement due to statement timeout
    
    or
    ActiveRecord::StatementInvalid: PG::QueryCanceled: ERROR:  cancelling statement due to user request
    
  • Application Unresponsiveness: Your development server (e.g., Rails, Node.js) may become unresponsive as it waits for database connections to free up.

To confirm this locally, you can use psql to check the pg_stat_activity view:

-- Connect to your PostgreSQL database
psql -h localhost -U your_user -d your_database

-- In the psql prompt, run this query to identify blocked queries
SELECT
    blocked_activity.pid AS blocked_pid,
    blocked_activity.usename AS blocked_user,
    blocked_activity.application_name AS blocked_application,
    blocked_activity.client_addr AS blocked_client_ip,
    blocked_activity.query_start AS blocked_query_start,
    blocked_activity.state AS blocked_state,
    blocked_activity.wait_event_type AS blocked_wait_event_type,
    blocked_activity.wait_event AS blocked_wait_event,
    blocked_activity.query AS blocked_query,
    blocking_activity.pid AS blocking_pid,
    blocking_activity.usename AS blocking_user,
    blocking_activity.application_name AS blocking_application,
    blocking_activity.client_addr AS blocking_client_ip,
    blocking_activity.query_start AS blocking_query_start,
    blocking_activity.state AS blocking_state,
    blocking_activity.wait_event_type AS blocking_wait_event_type,
    blocking_activity.wait_event AS blocking_wait_event,
    blocking_activity.query AS blocking_query
FROM pg_stat_activity AS blocked_activity
JOIN pg_locks AS blocked_locks
    ON blocked_activity.pid = blocked_locks.pid
    AND NOT blocked_locks.granted
JOIN pg_locks AS blocking_locks
    ON blocked_locks.relation = blocking_locks.relation
    AND blocking_locks.granted
    AND blocked_locks.pid != blocking_locks.pid
JOIN pg_stat_activity AS blocking_activity
    ON blocking_locks.pid = blocking_activity.pid
WHERE blocking_activity.query ILIKE '%VACUUM FULL%'
ORDER BY blocked_activity.query_start;

Expected output showing a VACUUM FULL blocking a SELECT:

 blocked_pid | blocked_user | blocked_application | blocked_client_ip |     blocked_query_start     | blocked_state | blocked_wait_event_type | blocked_wait_event |           blocked_query           | blocking_pid | blocking_user | blocking_application | blocking_client_ip |     blocking_query_start    | blocking_state | blocking_wait_event_type | blocking_wait_event |    blocking_query
-------------+--------------+---------------------+-------------------+-----------------------------+---------------+-------------------------+--------------------+-----------------------------------+--------------+---------------+----------------------+--------------------+-----------------------------+----------------+--------------------------+---------------------+--------------------
       54321 | app_user     | psql                | 127.0.0.1         | 2026-10-12 10:30:15.123456  | active        | Lock                    | relation           | SELECT * FROM large_table WHERE id = 1; |        12345 | postgres      | psql                 | 127.0.0.1          | 2026-10-12 10:29:00.000000  | active         | Client                               | ClientRead          | VACUUM FULL large_table;
(1 row)

Root Cause Analysis

The root cause of this issue lies in the fundamental behavior of the VACUUM FULL command in PostgreSQL:

  1. VACUUM FULL Operation: Unlike a regular VACUUM (which only marks dead tuples for reuse and allows concurrent DML/DQL), VACUUM FULL completely rewrites the target table to a new file, compacting it and reclaiming disk space directly back to the operating system.
  2. ACCESS EXCLUSIVE Lock: To perform this full rewrite safely and ensure data integrity, VACUUM FULL must acquire the strongest possible lock on the table: an ACCESS EXCLUSIVE lock. This lock prevents all other concurrent operations—reads, writes, and schema changes—on that specific table for the entire duration of the VACUUM FULL command.
  3. Duration: Depending on the table's size and activity, a VACUUM FULL can take a significant amount of time, from seconds to hours. During this period, any application query attempting to access the locked table will simply hang or eventually time out.
  4. Local Environment Context: On a macOS local environment, users might run VACUUM FULL to reclaim disk space after large data deletions or to simply experiment with database maintenance. Without proper understanding of its implications, it can easily lead to development slowdowns. Automated scripts or misconfigured IDEs could also trigger it.

Step-by-Step Resolution

To resolve an ongoing VACUUM FULL lock and prevent future occurrences, follow these steps.

1. Identify the Blocking VACUUM FULL Process

First, you need to precisely locate the VACUUM FULL process causing the blockage.

  1. Connect to PostgreSQL:

    • Homebrew Installation:
      psql -h localhost -U your_user -d your_database
      
    • Docker Container: If your PostgreSQL is running in a Docker container, find the container ID or name:
      docker ps
      
      Then connect to it:
      docker exec -it <container_name_or_id> psql -U your_user -d your_database
      
  2. Query for VACUUM FULL Process: Once connected, run the following SQL query to find the PID (Process ID) of the VACUUM FULL operation:

    SELECT
        pid,
        usename,
        application_name,
        state,
        query_start,
        query,
        pg_relation_size(l.relation::regclass) AS table_size,
        pg_size_pretty(pg_relation_size(l.relation::regclass)) AS pretty_table_size
    FROM
        pg_stat_activity a
    JOIN
        pg_locks l ON a.pid = l.pid
    WHERE
        a.query ILIKE '%VACUUM FULL%'
        AND l.granted = true
        AND l.mode = 'AccessExclusiveLock'
    ORDER BY
        query_start DESC;
    

    Note the pid of the VACUUM FULL process. Let's assume it's 12345 for the following steps.

2. Terminate the Blocking Process

You have two options: gently cancel the query or forcefully terminate the session.

Terminating a VACUUM FULL operation can lead to a long rollback process, which itself can be resource-intensive and take a significant amount of time, potentially delaying the availability of the table even further. Only proceed if the immediate unblocking of queries is critical.

  1. Gently Cancel the Query: This sends a SIGINT signal to the backend process, allowing it to clean up gracefully.

    SELECT pg_cancel_backend(12345); -- Replace 12345 with the actual PID
    

    Check pg_stat_activity again after a moment to see if the process is gone.

  2. Forcefully Terminate the Session (If Cancellation Fails): If pg_cancel_backend doesn't work, you can terminate the entire session. This sends a SIGTERM signal.

    SELECT pg_terminate_backend(12345); -- Replace 12345 with the actual PID
    

    This should immediately free up the lock. Be aware of the potential for a lengthy rollback.

After successfully terminating the process, your blocked SELECT queries should resume or complete.

3. Understand and Mitigate Future VACUUM FULL Impact

To prevent this issue from recurring, understand the proper use of VACUUM FULL and consider alternatives.

VACUUM FULL is generally not recommended for routine maintenance on busy databases (including heavily used local development environments) due to its blocking nature. Use it sparingly and only when strictly necessary, ideally during planned maintenance windows.

  1. Avoid VACUUM FULL for Routine Maintenance:

    • PostgreSQL's autovacuum daemon handles most routine table maintenance (marking dead tuples, updating statistics) without blocking normal operations.
    • Regular VACUUM (without FULL) can be run concurrently with SELECT and INSERT queries (though UPDATE and DELETE might incur brief row-level locks). It reclaims space within the table file, making it available for reuse, but doesn't return it to the OS.
  2. Consider Alternatives for Space Reclamation:

    • pg_repack: For production or shared development environments, consider using pg_repack. This excellent tool (available via Homebrew: brew install pg_repack) can reorganize tables and indexes online, often without requiring an ACCESS EXCLUSIVE lock for most of its operation, significantly reducing downtime.
      # Example using pg_repack
      pg_repack -h localhost -U your_user -d your_database -t your_table_name
      
    • Partitioning: For very large tables experiencing rapid data churn and significant bloat, consider partitioning. Dropping an entire partition is a fast way to reclaim space.
    • Strategic TRUNCATE + Reload: If a table can be rebuilt, TRUNCATE TABLE is much faster than DELETE FROM and immediately reclaims all space. This is only viable if the data can be re-imported or regenerated.
  3. Schedule During Off-Peak Hours (If Essential): If VACUUM FULL is absolutely necessary (e.g., to reclaim substantial physical disk space on a local machine for a one-off task), schedule it when you know no other queries will be run against that database.

  4. Configure statement_timeout: Set a statement_timeout on your database or application to prevent queries from hanging indefinitely. This won't prevent the lock, but it will cause queries to fail gracefully after a set period, providing quicker feedback.

    • Globally (requires restart or reload): Edit your postgresql.conf file (for Homebrew, usually in /usr/local/var/postgres/postgresql.conf or a linked location; for Docker, mount a custom config).
      # postgresql.conf
      statement_timeout = '5min' # 5 minutes
      
      Then restart PostgreSQL:
      # Homebrew
      brew services restart postgresql
      # Docker (restart container)
      docker restart <container_name_or_id>
      
    • Per session:
      SET statement_timeout = '2min'; -- Sets timeout for the current session
      
    • Application-level: Most database drivers and ORMs allow setting timeouts directly in your application code or connection string.

4. Optimize Autovacuum Settings

While autovacuum doesn't perform VACUUM FULL, ensuring it's optimally configured helps keep tables healthy and reduces the temptation or perceived need for manual VACUUM FULL operations by minimizing general table bloat.

  1. Check Autovacuum Activity and Settings:

    -- Check global autovacuum settings
    SHOW autovacuum;
    SHOW autovacuum_vacuum_scale_factor;
    SHOW autovacuum_vacuum_threshold;
    SHOW autovacuum_vacuum_cost_delay;
    SHOW autovacuum_vacuum_cost_limit;
    
    -- Check autovacuum settings for a specific table
    SELECT
        relname,
        reloptions,
        pg_size_pretty(pg_total_relation_size(oid)) AS total_size
    FROM pg_class
    WHERE relname = 'your_table_name';
    
    -- View autovacuum progress
    SELECT * FROM pg_stat_progress_vacuum;
    
  2. Adjust Autovacuum Parameters (if necessary): If you have very active tables, you might need to adjust autovacuum_vacuum_scale_factor or autovacuum_vacuum_threshold (globally or per-table) to make autovacuum run more frequently. This usually applies more to production, but awareness is key even locally.

    -- Example: Change autovacuum settings for a specific table
    ALTER TABLE your_table_name SET (autovacuum_vacuum_scale_factor = 0.05); -- Runs more often
    ALTER TABLE your_table_name SET (autovacuum_vacuum_threshold = 5000);   -- Runs when 5000 dead tuples accumulate
    

By understanding the nature of VACUUM FULL and utilizing appropriate maintenance strategies, you can avoid frustrating table lock scenarios and maintain a smooth development workflow on your macOS PostgreSQL environment.

👨‍💻

Johnathon Wheeler

Senior Systems Architect & DevOps Engineer • Austin, TX

Connect on LinkedIn →

Johnathon has over 16 years of hands-on experience designing, debugging, and scaling Linux web hosting stacks, container clusters, and high-availability database architectures. Every guide on ButItWorkedLocal is independently tested against Debian 12, Ubuntu 24.04/22.04 LTS, Rocky Linux, and Docker environments to guarantee reproducibility in production.

🛡️

Our Production Verification Guarantee

Encountering a bug not covered here or running a non-standard kernel configuration? Our solutions are continually refined against real production incidents. Submit an environment trace for our editorial team to replicate.