Home.

Saving My Data After a SQL Mistake: Selective Restore in PostgreSQL

Finn Christiansen
Finn Christiansen

created at:

updated at:

Have you ever made a change and instantly regretted it? Well, I did — by accidentally deleting almost all my user data from a live database. Let’s dive into what happened, what I learned, and how I managed to recover my lost data without affecting new records.

How I deleted all my User data in Postgres (Story)

So here’s how it started. I was testing my web app, and I use a specific test email for all my checks: test@finnchristiansen.com. I even added unique numbers to the emails, like test+1, test+2, and so on, just to keep things organized.

But eventually, these test users were cluttering my database, so I figured, ‘Hey, let’s clean this up.’.

I hopped into the SQL console, ready to remove only those test accounts... and typed out:

DELETE FROM User;

I called it a day and went to the gym.

How I realized I deleted all the data in my user table in Postgres (Realization and Problem)

A few days later, I noticed something was offs. My user numbers were way lower than they should’ve been.

Then it hit me. I had deleted everything. Every single user.

All because I forgot one small, crucial detail: the WHERE clause.

DELETE FROM User
WHERE email LIKE 'test+%';

‘No problem,’ I thought, ‘I have backups!

But it wasn’t that simple. Restoring a full backup would mean losing all the new data created after the deletion. So, I couldn’t just roll back the clock on my entire database. I needed a way to restore only the deleted users, without touching the new ones.

Selective Data Restore in Postgres (Solution Overview)

Here’s what I came up with—a selective data recovery plan. Instead of restoring everything, I’d use an RDS snapshot to create a temporary copy of the database, extract only the missing data, and merge it back into production. Here’s how I did it.

Creating a Temporary RDS Database In AWS

First, I used Amazon RDS to create a new database instance from a recent snapshot. But I wanted this to be temporary, so I made sure it was easy to delete afterward. I used Terraform to set up the snapshot database, configuring it in the same private network as my production environment, but with permissions that allowed for easy deletion later.

Selectively Extracting the some records from some tables in Postgres

Once the snapshot instance was live, I connected via an SSH tunnel to export only the data I needed.

This simple SQL command helped me pull just the missing user records into a CSV file.

COPY (
  SELECT *
  FROM users
  WHERE email LIKE 'test+%'
) TO '/tmp/users_backup.csv' DELIMITER ',' CSV HEADER;

Restoring Data from a CVS file to a Postgres database table

With the data saved locally, I reconnected to the production database and used another SQL command to import it.

This way, I could bring back the deleted test users without overwriting any new ones.

COPY users (id, firstname, lastname, email, ...)
FROM '/tmp/users_backup.csv'
DELIMITER ','
CSV HEADER;

And just like that, my data was back!

Lessons Learned

So, what did I learn from this?

First, never make changes on the fly without a safety check. It’s so easy to make a mistake in SQL, especially without testing.

Next time, I’d probably create a dedicated admin route to delete test users, making sure it’s reviewed carefully.

app.post('/admin/delete-test-users', (req, res) => {
  // code with sql statements
  res.send('succesfully deleted test users')
})

Bonus: Restoring a DB snapshot using Terraform

Here is the code for the existing instance:

resource "aws_db_instance" "this" {
  identifier             = "main-db"
  db_name                = "platform"
  instance_class         = "db.t4g.micro"
  allocated_storage      = 20
  engine                 = "postgres"
  engine_version         = "15.7"
  skip_final_snapshot    = false # a final snapshot will be created
  publicly_accessible    = false
  vpc_security_group_ids = [aws_security_group.db.id]
  username               = aws_ssm_parameter.db_username.value
  password               = aws_ssm_parameter.db_password.value
  db_subnet_group_name   = aws_db_subnet_group.this.name

  # backup configuration
  backup_retention_period = 35 # number in days
  backup_window           = "02:00-03:00" # daily time in which backups are performed

  deletion_protection     = true
}

The snapshot instance is then this customized code:

resource "aws_db_instance" "snapshot" {
  identifier             = "main-db-snapshot" # important to use other name
  instance_class         = "db.t4g.micro"
  #engine                 = "postgres"
  engine_version         = "15.7"

  publicly_accessible    = false
  vpc_security_group_ids = [aws_security_group.db.id]
  db_subnet_group_name   = aws_db_subnet_group.this.name
  snapshot_identifier = "rds:diggital-platform-production-hono-2024-10-16-02-10" # this is the important part

  # easy deletion of db
  deletion_protection     = false
  skip_final_snapshot    = true
}

Leave out db_name, username and password because they are contained in the backup.

Disable deletion_protection and enable skip_final_snapshot to make it easy to delete the database. remember it is only there to quickly access the deleted data.

Tools

Code Snipes can be formated to an image using Chalk.ist