How to Reclaim Disk Space in AWS Aurora MySQL







How to Reclaim Disk Space in AWS Aurora MySQL After Deleting Records — Without Downtime


Database Engineering · Space Reclamation

How to Reclaim Disk Space in AWS Aurora MySQL
without taking your table offline

You deleted millions of records. The database is still just as big. Here’s why — and exactly how to fix it, live, with zero downtime.

15 min read

Step-by-step guide

Developer + Non-Tech editions

Aurora MySQL 2.x / 3.x


Why deleting records doesn’t free disk space

For developers

Aurora MySQL uses InnoDB as its storage engine. InnoDB organises data in B-tree pages (typically 16 KB each). When you run a DELETE, InnoDB marks the affected rows and pages as free — available for future INSERTs to reuse — but it does not shrink the physical .ibd tablespace file on disk.

This is intentional: immediate deallocation would be catastrophically expensive on large tables. The tradeoff is that fragmentation accumulates over time. The data_free column in information_schema.tables tells you how many bytes are “free” inside the file but unreclaimed by the OS.

To actually compact the file, you must rebuild the table — which copies all live rows into a fresh structure and discards the holes.

🏢
For non-technical readers

Think of your database table like a physical filing cabinet. When you remove documents (delete records), you pull them out and leave empty folders in their slots. The cabinet is still the same size — it still takes up the same floor space — even though a third of the drawers are now empty.

AWS charges you for the storage that cabinet occupies, whether the drawers are full or empty. And a half-empty cabinet is slower to search than a tightly packed one, because the system still has to flip through all the gaps.

To genuinely shrink the cabinet, you’d need to reorganise it — move everything to one end and remove the now-empty drawers. That’s what this guide does, but for your database.

Space freed by DELETE alone
0%
InnoDB keeps the .ibd file intact

Typical reclaim after rebuild
40–80%
Varies by row size & delete ratio

Table downtime required
0 min
With pt-osc or gh-ost

Extra storage needed during rebuild
~1×
Table size, temporarily

Why you should care — performance & cost impact

Technical impact

Index fragmentation: B-tree pages with low fill factors cause more page reads per query. A table that once needed 4 I/O operations to scan now needs 7.

Buffer pool efficiency: Aurora’s InnoDB buffer pool caches 16 KB pages. Fragmented pages carry a lot of empty space — you’re caching air, which reduces the effective number of live rows in memory.

IOPS cost: AWS Aurora charges per I/O operation. A fragmented table burns more IOPS on the same query compared to a compacted one.

Backup size: Aurora snapshots capture the full allocated tablespace, including free pages. A 200 GB tablespace with 80 GB of free space still creates a ~200 GB snapshot.

🏢

Business impact

Slower app performance: Customer-facing pages backed by fragmented tables take longer to load. A table that’s 60% fragmented can be 30–50% slower to query.

Higher AWS bill: You’re paying for storage and I/O on data that doesn’t exist anymore. On a 500 GB table that’s 70% empty rows, that’s ~350 GB of unnecessary storage cost every month.

Risk of storage exhaustion: If your Aurora instance runs out of storage, it will become read-only, which stops all writes to your application. Reclaiming space is a proactive safety measure.

Compliance: In regulated industries, physically removing deleted data from the underlying tablespace may be required by policy — not just logically deleting rows.

Diagnosing your table — is it worth reclaiming?

Before scheduling any work, run these queries to understand your starting point. A table under 15% fragmentation generally doesn’t justify the risk and effort of a live rebuild.

Step 1 — Find your most fragmented tables

SQL · Run on Aurora Writer endpoint

-- Lists all tables sorted by fragmentation percentage
SELECT
  table_schema                                         AS db_name,
  table_name,
  ROUND(data_length  / 1024 / 1024, 2)              AS data_mb,
  ROUND(index_length / 1024 / 1024, 2)             AS index_mb,
  ROUND(data_free    / 1024 / 1024, 2)              AS free_mb,
  ROUND(
    data_free / (data_length + index_length) * 100, 1
  )                                                    AS frag_pct
FROM  information_schema.tables
WHERE table_schema = 'your_database_name'
  AND   engine       = 'InnoDB'
ORDER BY frag_pct DESC
LIMIT 20;

Step 2 — Estimate rebuild time

SQL · Sizing estimate

-- Row count and physical size for a specific table
SELECT
  table_rows,
  ROUND((data_length + index_length) / 1073741824, 3) AS total_gb,
  ROUND(data_free               / 1073741824, 3) AS reclaimable_gb,
  create_time,
  update_time
FROM  information_schema.tables
WHERE table_schema = 'your_database_name'
  AND   table_name   = 'your_table_name';

💡

Rule of thumb for estimating rebuild duration

pt-osc copies roughly 10,000–50,000 rows/second on a typical Aurora db.r5.2xlarge. Divide your row count by 20,000 and you have a rough hour estimate. A 100 million row table ≈ ~1.5 hours at default chunk sizes.

Step 3 — Check for foreign key dependencies

SQL · FK check

-- Tables that reference your target table (child tables)
SELECT
  kcu.table_name          AS child_table,
  kcu.column_name         AS fk_column,
  kcu.constraint_name,
  rc.update_rule,
  rc.delete_rule
FROM  information_schema.key_column_usage kcu
JOIN  information_schema.referential_constraints rc
  ON  rc.constraint_name   = kcu.constraint_name
  AND rc.constraint_schema = kcu.constraint_schema
WHERE kcu.referenced_table_name   = 'your_table_name'
  AND   kcu.referenced_table_schema = 'your_database_name';

⚠️

Foreign keys require extra flags

If your table has child tables (FKs pointing at it), pt-osc requires the --alter-foreign-keys-method=rebuild_constraints or auto flag. Skipping this will cause the FK to point at the old table name after the rename and silently break referential integrity.

Pre-flight checklist — do this before running anything

  • Verify Aurora storage headroom — Check CloudWatch metric FreeStorageSpace. You need at least 1× the table’s total size free. pt-osc creates a full shadow copy.
  • Confirm you’re on the Writer endpoint — Run SELECT @@read_only; — must return 0. Reader endpoints reject DDL and will cause the tool to fail mid-copy.
  • Check Aurora replica lag baseline — Note your current AuroraReplicaLag in CloudWatch. If lag is already elevated before you start, wait — the rebuild will amplify it.
  • Run a dry-run first — Both pt-osc and gh-ost support --dry-run mode. Run it first to catch permission errors, FK issues, and missing binlog access before committing.
  • Create an Aurora snapshot — Take a manual snapshot before starting. Aurora snapshots are nearly instant. This is your rollback if something catastrophic happens.
  • Notify your team & schedule off-peak — Even though the table stays live, the rebuild increases CPU and I/O load. Run it during your lowest-traffic window.
  • Verify MySQL user permissions — The executing user needs: ALTER, CREATE, DROP, INSERT, UPDATE, DELETE, SELECT, TRIGGER, REPLICATION SLAVE on the target database.
  • Test on a non-production clone first — Aurora makes cloning trivial. Restore from snapshot to a test cluster and do a full rehearsal including timing.

Choosing your tool — a complete comparison

Method Table lock? Best for Aurora compatible? Rollback ease
pt-online-schema-change None (triggers) General purpose, most tested option Yes ✓ Easy — drop shadow table
gh-ost None (binlog) Very high write throughput tables Needs ROW binlog + extra config Easy — drop ghost table
ALTER TABLE INPLACE Short metadata lock Low-traffic tables, dev/staging Yes ✓ Medium — original preserved until swap
OPTIMIZE TABLE Full table lock Maintenance windows only Yes ✓ Difficult — table locked during run
Partition + DROP PARTITION None (partition-level) Time-series / archival tables with partition key Yes ✓ Medium — requires schema redesign
📌

Our recommendation for 24×7 Aurora tables

Start with pt-online-schema-change. It has the longest production track record, the best Aurora compatibility, and the most forgiving throttle controls. Use gh-ost only if you have very high sustained write throughput (>10K writes/sec) where trigger overhead is measurable.

Method 1 — pt-online-schema-change, step by step

How it works internally

pt-osc creates a new table (_tablename_new), adds three triggers (INSERT/UPDATE/DELETE) on the original table to mirror writes, copies rows in configurable chunks, then performs an atomic RENAME swapping the tables. The old table is renamed to _tablename_old and dropped.

🏢

In plain English

Imagine building a brand-new filing cabinet next to your existing one. While you slowly move files from the old cabinet to the new one, a helper watches the old cabinet and immediately copies any new or changed files to the new one too. When the move is complete, you swap the cabinet labels overnight — and no one notices any interruption.

  1. Install Percona Toolkit on your bastion or ops server

    pt-osc does not run inside Aurora — it runs on an EC2 instance or your local machine and connects to Aurora over the network.

    Bash · Amazon Linux 2

    # Add the Percona yum repository
    sudo yum install -y https://repo.percona.com/yum/percona-release-latest.noarch.rpm
    sudo percona-release setup pt
    sudo yum install -y percona-toolkit
    
    # Verify installation
    pt-online-schema-change --version
    # → pt-online-schema-change 3.x.x

  2. Run a dry-run to validate everything

    A dry-run performs all checks — permissions, FK detection, table structure analysis — without making any changes. Always run this first.

    Bash · pt-osc dry-run

    pt-online-schema-change \
      --alter  "ENGINE=InnoDB"        \
      --dry-run                    \
      --print                      \
      D=your_database,t=your_table  \
      h=your-aurora-writer.cluster-xxxx.region.rds.amazonaws.com \
      u=admin,p=your_password

    💡

    Look for the line “Would have used X triggers” in the output. If you see errors about missing privileges or FK issues, fix them before proceeding.

  3. Run the live operation with throttling controls

    This is the full production command. Break it down flag by flag below.

    Bash · Production run (inside a screen or tmux session)

    pt-online-schema-change \
      --alter               "ENGINE=InnoDB"           \
      --execute                                         \
      --chunk-size          1000                        \
      --chunk-size-limit    4                           \
      --max-load           "Threads_running=50"       \
      --critical-load      "Threads_running=100"      \
      --max-lag             5                           \
      --pause-file          /tmp/pt-osc-pause.flag       \
      --alter-foreign-keys-method auto                  \
      --no-drop-old-table                               \
      --print                                           \
      --progress            time,30                     \
      D=your_database,t=your_table                      \
      h=aurora-writer-endpoint \
      u=admin,p=secret

    Flag-by-flag explanation

    Flag What it does Recommended value
    --chunk-size Rows copied per transaction batch Start at 500–1000; increase if lag stays low
    --max-load Pauses copying if Aurora exceeds this load Threads_running=50 (adjust for your instance)
    --critical-load Aborts the operation at this load threshold Threads_running=100
    --max-lag Pauses if Aurora replica lag exceeds N seconds 5 — protect read replicas
    --pause-file Pauses immediately if this file exists on disk Any path — touch the file to pause, delete to resume
    --no-drop-old-table Keeps the original table as a backup after rename Always use this on first run
    --alter-foreign-keys-method How to handle FK constraints on child tables auto — pt-osc picks the safest method
    --progress Print progress every N seconds time,30 — status every 30 seconds

  4. Emergency pause — if you need to stop immediately

    Bash · Pause and resume

    # Pause the copy immediately (pt-osc checks this file every second)
    touch /tmp/pt-osc-pause.flag
    
    # Resume by removing the pause file
    rm /tmp/pt-osc-pause.flag
    
    # Hard stop (Ctrl+C or kill) — safe to do, the shadow table is just dropped
    kill $(pgrep -f pt-online-schema-change)

    🚨

    If pt-osc is killed mid-run

    The original table is untouched — it’s still running normally. pt-osc will have left behind a shadow table (_tablename_new) and three triggers (pt_osc_*). Clean them up manually: DROP TABLE IF EXISTS _tablename_new; and DROP TRIGGER IF EXISTS pt_osc_ins_tablename; etc. before restarting.

  5. Verify the reclaim succeeded

    SQL · Post-run verification

    -- Compare before vs after
    SELECT
      table_name,
      ROUND((data_length + index_length) / 1073741824, 3) AS total_gb,
      ROUND(data_free               / 1073741824, 3) AS free_gb,
      ROUND(data_free / (data_length + index_length) * 100, 1) AS frag_pct
    FROM  information_schema.tables
    WHERE table_schema = 'your_database_name'
      AND   table_name IN ('your_table', '_your_table_old');
    
    -- Confirm row counts match
    SELECT COUNT(*) FROM your_table;
    
    -- Once confirmed, drop the old backup table
    DROP TABLE IF EXISTS _your_table_old;

Method 2 — gh-ost (GitHub Online Schema Tool)

gh-ost is GitHub’s alternative to pt-osc. Instead of triggers, it tails the MySQL binary log to capture changes — this reduces write amplification and makes it safer for very high-throughput tables. The tradeoff is more complex Aurora setup.

📋

Aurora requirement: enable binary logging

gh-ost requires binlog_format=ROW and log_bin=ON. In Aurora, set this in your Parameter Group: navigate to RDS → Parameter Groups → your cluster parameter group → set binlog_format to ROW. This requires a cluster restart if not already set.

  1. Install gh-ost

    Bash · Install from GitHub releases

    # Download the latest binary
    wget https://github.com/github/gh-ost/releases/latest/download/gh-ost-binary.tar.gz
    tar -xzf gh-ost-binary.tar.gz
    sudo mv gh-ost /usr/local/bin/
    
    gh-ost --version
    # → gh-ost version 1.x.x

  2. Dry-run to validate Aurora connectivity and binlog access

    Bash · gh-ost dry-run

    gh-ost \
      --mysql-port=3306 \
      --host=aurora-writer-endpoint \
      --user=admin \
      --password=secret \
      --database=your_database \
      --table=your_table \
      --alter="ENGINE=InnoDB" \
      --allow-on-master \
      --switch-to-rbr \
      --test-on-replica=false \
      --dry-run

  3. Execute the live migration

    Bash · gh-ost production run

    gh-ost \
      --mysql-port=3306                                              \
      --host=aurora-writer-endpoint                                    \
      --user=admin                                                     \
      --password=secret                                               \
      --database=your_database                                        \
      --table=your_table                                              \
      --alter="ENGINE=InnoDB"                                        \
      --chunk-size=1000                                               \
      --max-load="Threads_running=50"                               \
      --critical-load="Threads_running=100"                          \
      --max-lag-millis=5000                                          \
      --allow-on-master                                               \
      --switch-to-rbr                                                 \
      --serve-socket-file=/tmp/gh-ost.sock                           \
      --panic-flag-file=/tmp/gh-ost-panic.flag                       \
      --postpone-cut-over-flag-file=/tmp/gh-ost-postpone.flag        \
      --execute

    Runtime controls via socket

    Bash · gh-ost runtime commands

    # Check status while running
    echo status | nc -U /tmp/gh-ost.sock
    
    # Throttle down chunk size live (no restart needed)
    echo "chunk-size=500" | nc -U /tmp/gh-ost.sock
    
    # Postpone the final cut-over (table rename) until you're ready
    touch /tmp/gh-ost-postpone.flag
    # Remove file when ready to complete the cut-over
    rm /tmp/gh-ost-postpone.flag
    
    # Emergency abort
    touch /tmp/gh-ost-panic.flag

Monitoring during the rebuild — CloudWatch & MySQL metrics

Key CloudWatch metrics to watch in real time

Metric Namespace Alert threshold Action if breached
AuroraReplicaLag AWS/RDS > 5,000 ms Reduce chunk size or pause via pause file
CPUUtilization AWS/RDS > 80% Reduce chunk size; check for missing indexes on shadow table
FreeStorageSpace AWS/RDS < 10 GB Abort immediately — Aurora will become read-only if storage fills
Deadlocks AWS/RDS > 5/min Reduce chunk size; check application retry logic
DatabaseConnections AWS/RDS > 80% max_connections Reduce pt-osc parallelism or pause

Live progress query inside MySQL

SQL · Monitor progress from a second session

-- Show what pt-osc is currently doing
SHOW PROCESSLIST;

-- Check the shadow table's row count vs original (progress estimate)
SELECT
  (  SELECT COUNT(*) FROM your_table          ) AS original_rows,
  (  SELECT COUNT(*) FROM _your_table_new     ) AS shadow_rows,
  ROUND(
    (  SELECT COUNT(*) FROM _your_table_new   ) /
    (  SELECT COUNT(*) FROM your_table        ) * 100, 1
  )                                                AS pct_complete;

-- Check for trigger presence (confirms pt-osc is running)
SHOW TRIGGERS LIKE 'your_table';

What a healthy run looks like — phase by phase

P1

Setup phase ~seconds

pt-osc creates the shadow table with identical schema, adds the three copy triggers to the original table, and validates FK dependencies. You’ll see CREATE TABLE _tablename_new in the process list briefly.

P2

Row copy phase minutes to hours

The main work — chunked SELECT INTO on the original table, inserting to shadow. Progress messages appear every 30s if you used --progress time,30. CPU and I/O will be elevated. Replica lag may increase slightly.

P3

Cut-over phase ~milliseconds

pt-osc acquires a very brief metadata lock (<1 second typical), renames tablename → _tablename_old and _tablename_new → tablename atomically. Drops the three triggers. Your application seamlessly continues on the compacted table.

P4

Cleanup phase ~seconds

If you used --no-drop-old-table, the old table remains as _tablename_old. Verify data integrity, then run DROP TABLE _tablename_old to release the disk space that the old (fragmented) copy was still occupying.

⚠️

Don’t forget to drop the old table

With --no-drop-old-table, the original fragmented table persists as _tablename_old and continues occupying Aurora storage. Your net storage usage temporarily doubles during the rebuild — and stays doubled until you manually drop the old table after verifying everything is correct. Set a calendar reminder.

Quick reference — the complete playbook

Developer recap

1. Run fragmentation query → confirm >15% frag_pct

2. Check FK dependencies, storage headroom, writer endpoint

3. Take Aurora snapshot

4. Install pt-osc, run --dry-run

5. Run --execute in tmux with --no-drop-old-table

6. Monitor CloudWatch: CPUUtilization, AuroraReplicaLag, FreeStorageSpace

7. Verify row count + frag_pct post-run → drop old table

🏢

Non-technical recap

What you approved: A database reorganisation that compacts storage space freed by deleted records.

Impact to users: None — the database stays fully operational throughout.

Expected outcome: 40–80% reduction in storage used by the target table, faster query performance, lower AWS I/O costs.

Risk level: Low — requires a snapshot beforehand, tool pauses automatically if load spikes, and original data is preserved until you confirm success.

Time to complete: Depends on table size. A 50 GB table typically takes 2–4 hours.


Leave a Reply

Your email address will not be published. Required fields are marked *