2026-03-05
Expert-Level PostgreSQL Deleted Data Recovery in Just 5 Steps — No Kernel Knowledge Required
by Zhang Chen
This article is for every PostgreSQL user — whether you are a DBA, developer, or ops engineer. By the end, you will have a complete, expert-grade playbook for recovering accidentally deleted or updated data, without ever needing to understand PostgreSQL internals.
Accidental Deletion — Every PostgreSQL DBA's Worst Nightmare
If you work with PostgreSQL, at least one of these scenarios will feel painfully familiar:
Scenario 1: The 3 AM alarm call. A developer ran a DELETE statement on the production database — without a WHERE clause. 500,000 customer records vanished in an instant. The application started throwing errors everywhere, and the support hotline exploded.
Scenario 2: A "harmless" data cleanup. During a maintenance window, an ops engineer ran an UPDATE script intended to change a single status field. A wrong filter condition meant every row in the table — millions of records — was overwritten with the same value. By the time anyone noticed, hours had passed.
Scenario 3: The wrong terminal window. A DBA was cleaning up leftover tables in a test environment. They switched terminal tabs — or so they thought. The delete command they ran actually hit the production database. Critical data, gone in a heartbeat.
These are not scare stories. According to community surveys and real-world incident reports, human error is the number-one cause of PostgreSQL data loss, far ahead of hardware failures and software bugs. The pattern is always the same: the damage happens instantly (a single SQL statement), the discovery is slow (minutes to hours later), and the recovery — using traditional methods — is agonizingly difficult.
Traditional Recovery Methods: High Cost, Higher Barrier
When disaster strikes, the industry's conventional recovery options include the methods below. Each has its place, but they all share the same problem: high complexity, high cost, and significant limitations.
PITR (Point-in-Time Recovery)
PITR is PostgreSQL's official recovery mechanism. It works by replaying WAL (Write-Ahead Log) files on top of a full base backup to "rewind" the database to a specific point in time.
Sounds great in theory. In practice, the pain points are real:
- You need a complete backup chain. PITR requires a full base backup plus every WAL file from that point forward. If a single WAL file is missing, replay stops dead. Many organizations discover their WAL archiving had gaps only after an incident.
- Recovery takes forever. For large databases (terabyte-scale), WAL replay can take hours or longer. The application remains down the entire time.
- The granularity is too coarse. PITR restores the entire database cluster to a point in time. If you only need to recover certain deleted rows in one table, PITR cannot do that — it rolls back every change made after the target timestamp, including legitimate ones. You then need additional effort to extract just the data you need.
- If the base backup is corrupt, recovery fails entirely.
pg_resetwal: A Dangerous Shortcut
pg_resetwal is a tool that resets the WAL state, often used to force-start an instance whose WAL is corrupted.
The core problem: it can cause data loss. pg_resetwal discards WAL records not yet flushed to data files, meaning recent transactions may be permanently lost. Worse, it modifies source files in place — the operation is irreversible. In a deleted-data scenario, running pg_resetwal can make things significantly worse.
pg_filedump + pg_dirtyread + pg_waldump: A Fragmented Toolchain
The PostgreSQL ecosystem offers several low-level tools that can assist with recovery:
| Tool | Purpose | Limitation |
|---|---|---|
| pg_filedump | Dump data file contents | Requires table structure knowledge; raw output needs manual parsing |
| pg_dirtyread | Read dead tuples (deleted rows not yet vacuumed) | Database must be running |
| pg_waldump | Inspect WAL file contents | Read-only; cannot perform recovery |
The shared problem: each tool solves only a small piece of the puzzle, and all require deep knowledge of PostgreSQL internals — heap page layout, system catalog structure, WAL record format, transaction handling, TOAST storage chunking, and version-specific behavioral differences.
For the vast majority of DBAs and developers, this expertise simply is not available. And in the heat of a production incident, there is no time to learn it on the fly.
PDU: Expert-Level Deleted Data Recovery in 5 Steps
To address all of the above, a tool called PDU (PostgreSQL Data Unloader) was created.
PDU is a disaster recovery tool purpose-built for PostgreSQL. Open-source repository: https://github.com/wublabdubdub/PDU-PostgreSQLDataUnloader
It consists of just two files — one executable and one configuration file. All operations are strictly read-only and never modify source data files. It provides a psql-style command interface, so PostgreSQL users can start using it with zero learning curve.
Below is a real-world accidental deletion recovery, completed in just five steps.
Configure pdu.ini — Specify the Data Directory and Archive Path
PDU's configuration is minimal — two key path parameters. Edit pdu.ini:
[root@node1 pdu]# cat pdu.ini
#PostgreSQL Data Directory
PGDATA=/home/pg/data/
#PostgreSQL Archive Directory
ARCHIVE_DEST=/home/pg/wal_arch
#Disk for dropScan to scan
DISK_PATH=
#Number of data blocks to skip during dropScan
BLOCK_INTERVAL=20
#PGDATA to be excluded during dropscan
PGDATA_EXCLUDE=
Key notes:
PGDATA: Points to the PostgreSQL data directory. This is required.ARCHIVE_DEST: Points to the WAL archive directory. The core mechanism for recovering deleted/updated data is extracting and reassembling data from WAL files, so this path is critical. Make sure your database has WAL archiving enabled. Immediately after discovering the accidental operation, run the following commands to ensure WAL is flushed and archived:
SELECT pg_switch_wal();
CHECKPOINT;
Tip: If the archive directory contains more than 250 WAL files, you will need to narrow the scan range using the startwal parameter. We cover this in the "Advanced Usage" section below.
Start PDU and Initialize
Navigate to the PDU directory, launch the program, and run the initialization command b;:
[root@node1 pdu]# ./pdu
╔══════════════════════════════════════════════════════╗
║ PDU: PostgreSQL Data Unloader ║
║ Version 3.0.25.10 (2025-10-13) ║
╚══════════════════════════════════════════════════════╝
PDU.public=# b;
Starting initialization...
-pg_database:</home/pg/data/global/1262>
Database:postgres
-pg_schema:</home/pg/data/base/13023/2615>
-pg_class:</home/pg/data/base/13023/1259> Total 80 rows
Schemas:
▌ public 0 tables
Database:mydb
-pg_schema:</home/pg/data/base/24576/2615>
-pg_class:</home/pg/data/base/24576/1259> Total 11213 rows
Schemas:
▌ public 1071 tables
▌ app 193 tables
▌ hr 221 tables
During initialization, PDU reads system catalog files (pg_database, pg_class, pg_attribute, etc.) directly from the PGDATA directory to automatically parse all database, schema, and table metadata. This process is purely read-only and does not require PostgreSQL to be running.
Navigate to the Target Database and Schema
Use the use command to switch to the database containing the deleted data, then set to switch to the target schema:
PDU.public=# use mydb;
┌────────────────────────────────────────┐
│ Schema │ Tab Num │
├────────────────────────────────────────┤
│ public │ 1071 │
│ app │ 193 │
│ hr │ 221 │
└────────────────────────────────────────┘
mydb.public=# set public;
After entering the database, PDU lists all schemas and their table counts. To inspect a table's structure, use the familiar \d+ table_name; command — exactly like psql.
Scan — Find Deleted/Updated Records in the WAL
This is the core recovery step. Use the scan command to search the WAL archive for delete or update records on the target table:
mydb.public=# scan t_payment_records;
Scanning deleted Records for table<t_payment_records>...
▌ Scanning Archived Wal Directory
StartWal: 000000030000052500000050
EndWal: 0000000300000525000000EF
▌ Time Range Restore Mode [Displayed all within Time Range]
────────────────────────────────────────
▌ End of Scanning, current time range:
Start: 2025-03-05 05:05:38.006348 EST
End: 2025-03-05 06:00:19.515825 EST
▌ Time Range Details
┌─────────────────────────────────────────────────────────┐
Start Time: 2025-03-05 05:05:38.006348 EST
End Time: 2025-03-05 06:00:19.515825 EST
LSN: 525/532B3990 - 525/DCAF8FD0
Recommended startwal: 000000030000052500000053
Recommended endwal: 0000000300000525000000DC
-------------------.--------------------
● Datafile OID: 144835 ● Toastfile OID: 64598
● Records deleted in the Time Range: 9648
└─────────────────────────────────────────────────────────┘
Execution Time 38.30 seconds
The scan results tell you clearly: within this time range, the table has 9,648 deleted records available for recovery, along with precise time ranges, LSN coordinates, and the WAL file range involved.
Restore — One Command to Recover
Once you have confirmed the scan results, execute the restore command:
Recover all deleted records:
mydb.public=# restore del all;
▌ Scanning Archived Wal Directory
StartWal: 000000030000052500000050
EndWal: 0000000300000525000000EF
▌ Time Range Restore Mode [Displayed all within Time Range]
────────────────────────────────────────
|-Records Decoded: 2
Missing FPI blk 272 found during restoreDEL, single record parsing failed
Missing FPI blk 253 found during restoreDEL, single record parsing failed
|-Records Decoded: 9646
▌ Restore Complete
┌───────────────────────────────────────────────────────────────┐
Table <t_payment_records>
● Records Restored: 9648
● Success: 9646 ● Failure: 2
● File Path: restore/public/t_payment_records_del_2025-03-05
05:05:38.006348 EST_2025-03-05 06:00:19.515825 EST.csv
└───────────────────────────────────────────────────────────────┘
Execution Time 18.49 seconds
Out of 9,648 records, 9,646 were successfully recovered — only 2 failed due to missing Full Page Images (FPI). The recovered data is output as a CSV file, ready to be imported back into PostgreSQL via the COPY command.
About "Missing FPI" errors: If you see these, it means the full-page images for certain WAL records are not present. The fix is simple — include more WAL files in the scan range by setting the startwal parameter to an earlier file.
Recovering accidentally updated records is just as easy. Simply switch the restore type before scanning:
mydb.public=# p restype update;
mydb.public=# scan t_user_profiles;
mydb.public=# restore upd all;
The resulting CSV contains the original values from before the UPDATE. You can use these to construct UPDATE statements to revert the data, or import them into a temporary table for selective comparison and recovery.
That is it — five steps, full recovery. The entire process takes under a minute (excluding WAL scan time), requires no understanding of data page formats, no custom parsing scripts, and no database downtime.
Advanced Usage: Precision Recovery by Transaction ID and Time Range
In real production environments, you often do not want to recover everything. Instead, you want to precisely recover the results of one specific accidental operation. PDU provides two recovery modes and rich parameter controls for exactly this purpose.
Transaction Mode (TX Mode) — Recover by Transaction ID
By default, PDU uses "Time Range Mode," which recovers all delete/update operations within a specified time window. To recover only a specific transaction's operations, switch to "Transaction Mode":
# Switch to transaction recovery mode
mydb.public=# p resmode tx;
Run scan again, and PDU groups the results by transaction:
mydb.public=# scan t_payment_records;
▌ Tx Restore Mode [Displayed by Tx groups]
────────────────────────────────────────
▌ Tx Details
┌─────────────────────────────────────────────────────────┐
Timestamp: 2025-03-05 05:06:20.420863 EST
LSN: 525/532B3990 - 525/532B4470
Recommended startwal: 000000030000052500000053
Recommended endwal: 000000030000052500000053
-------------------.--------------------
● Tx Number: 1360118630
● Records deleted by the TX: 1
● Datafile OID: 144835 ● Toastfile OID: 64598
└─────────────────────────────────────────────────────────┘
▌ Tx Details
┌─────────────────────────────────────────────────────────┐
Timestamp: 2025-03-05 05:48:56.389241 EST
LSN: 525/CAA750D0 - 525/CAABCEC0
Recommended startwal: 0000000300000525000000CA
Recommended endwal: 0000000300000525000000CA
-------------------.--------------------
● Tx Number: 1360536375
● Records deleted by the TX: 50
● Datafile OID: 144835 ● Toastfile OID: 64598
└─────────────────────────────────────────────────────────┘
Now you can see exactly how many records each transaction deleted. Suppose you only need to recover the 50 records deleted by transaction 1360536375:
mydb.public=# restore del 1360536375;
▌ Restore Complete
┌───────────────────────────────────────────────────────────────┐
Table <t_payment_records>
● Records Restored: 50
● Success: 50 ● Failure: 0
● File Path: restore/public/t_payment_records_1360536375.csv
└───────────────────────────────────────────────────────────────┘
Execution Time 17.93 seconds
Pinpoint the problem, pinpoint the fix — no collateral damage.
Time Range Control — Narrow the Scan Window
When the WAL archive directory contains too many files (over 250), PDU will prompt you to define a scan range. Use these parameters for fine-grained control:
# Set the starting WAL file for the scan
mydb.public=# p startwal 000000010000000100000047;
# Set the ending WAL file for the scan
mydb.public=# p endwal 0000000100000001000000DC;
# Or filter by time range
mydb.public=# p starttime 2025-03-05 05:00:00;
mydb.public=# p endtime 2025-03-05 07:00:00;
Full Parameter Reference
Use show; or param; to view and manage all current parameter settings:
mydb.public=# show;
┌─────────────────────────────────────────────────────────────────┐
│ parameter │ value │
├─────────────────────────────────────────────────────────────────┤
│ startwal │ │
│ endwal │ │
│ starttime │ │
│ endtime │ │
│ resmode(Data Restore Mode) │ TIME │
│ restype(Data Restore Type) │ DELETE │
└─────────────────────────────────────────────────────────────────┘
Parameter reference:
- resmode: Recovery mode.
TIMEfor time-range mode (default),TXfor transaction mode. Set with:p resmode tx;orp resmode time; - restype: Recovery type.
DELETEto recover deleted records (default),UPDATEto recover pre-update values. Set with:p restype update;orp restype delete; - startwal / endwal: Starting and ending WAL file names for the scan range. Use these when the archive contains many WAL files.
- starttime / endtime: Time-range filters that limit recovery to operations within the specified window.
By combining these parameters, you can achieve highly precise recovery targets — for example, "recover only the DELETE operations caused by transaction 1360536375 between 5:00 AM and 6:00 AM on March 5th."
Start Using PDU Today
If you manage PostgreSQL databases, we strongly recommend adding PDU to your toolkit before you need it. The best time to prepare for disaster recovery is before disaster strikes. Make sure your WAL archiving policy is properly configured, periodically verify archive file integrity, and when "that cursed SQL statement" inevitably gets executed, you can calmly open PDU, run five commands, and get your data back.
Final Thoughts
Data recovery should never be a task reserved for kernel experts. PDU's design philosophy — simplicity (two files), unity (one tool for all scenarios), safety (strictly read-only), familiarity (psql-style commands) — exists so that every PostgreSQL practitioner can perform fast, confident data recovery when the clock is ticking.
If you found this article helpful, please share it with your DBA colleagues and development teams. We also welcome you to star the GitHub repo, open issues, or join the community discussion:
🔗 GitHub: https://github.com/wublabdubdub/PDU-PostgreSQLDataUnloader
Remember: five steps to recovery — anyone can do it.
Comments (0)
No comments yet. Be the first to comment!