Running SQL safely
Transactions, checking before committing, undo scripts.
Part 1: The problem
A payment provider had a bad hour. Three QuickCart orders were left stuck as pending: the customers were never charged and the kitchens never cooked. Support asked Noor to cancel them.
Noor wrote the statement in her SQL editor over two lines, with the WHERE on the second. Then she selected only the first line and ran it:
UPDATE orders SET status = 'cancelled'The editor said 48112 rows affected. Every order QuickCart had ever taken was now cancelled, and the app was showing customers exactly that. Autocommit was on, so there was nothing to undo. The team restored the last backup, which was hours old, and lost every order placed since.
Neither mistake was about knowing SQL. Both were about running a change against production with no way to check it, and no way back. This lesson is the routine that gives you both.
Part 2: The idea
A transaction is a group of changes that the database treats as one. Either all of them happen, or none of them do. While it’s open, you can look at what you’ve changed and then decide.
| Statement | What it does |
|---|---|
START TRANSACTION | Opens a transaction. BEGIN does the same in the mysql client |
COMMIT | Makes every change in the transaction permanent and visible to everyone |
ROLLBACK | Throws away every change in the transaction, as if it never ran |
Autocommit: the default that catches people
MySQL starts every connection with autocommit on. It means every statement you run outside a transaction is committed the moment it finishes. An UPDATE typed on its own is permanent before you’ve read its output.
START TRANSACTION switches that off until the next COMMIT or ROLLBACK. That’s why the routine below always starts with it.
| You run | With autocommit on (the default) |
|---|---|
UPDATE orders SET … on its own | Permanent at once. ROLLBACK afterwards does nothing |
START TRANSACTION; then UPDATE orders SET … | Held in your session. ROLLBACK undoes it; COMMIT keeps it |
Nobody else sees it until you commit
While your transaction is open, other connections, including QuickCart’s app, keep reading the last committed data. MySQL’s default setting for this, called REPEATABLE READ, guarantees it. So you can make the change, look at it, and back out, and no customer ever sees the middle.
The catch is that the rows you’ve changed are locked until you finish. Anything else that tries to write to those rows waits. So open the transaction, check, and finish it within a minute or two. Never leave one open while you go for coffee.
Some statements can’t be rolled back
Transactions cover changes to data: INSERT, UPDATE and DELETE. Changes to the structure of the database, called DDL, are different. MySQL commits before and after each one, even in the middle of your transaction.
| Statement | Inside a transaction |
|---|---|
INSERT, UPDATE, DELETE | Can be rolled back |
CREATE TABLE, DROP TABLE | Commits at once, and commits your earlier changes too |
TRUNCATE TABLE | Commits at once. Every row is gone for good |
ALTER TABLE, RENAME TABLE | Commits at once |
That second point surprises people. If you run an UPDATE, then a CREATE TABLE, then ROLLBACK, the UPDATE stays. The CREATE TABLE committed it. You’ll see this happen in the lab.
The routine, every time
| # | Step | Why |
|---|---|---|
| 1 | Save the rows you’re about to change, and write the undo script | If the change turns out wrong after you commit, you can put the old values back. Do it before the transaction, because saving them is itself DDL |
| 2 | SELECT with exactly the same WHERE | Gives you the number of rows the change should touch |
| 3 | START TRANSACTION | From here on, nothing is permanent |
| 4 | Run the UPDATE or DELETE | Read Rows matched. It must equal the count from step 2 |
| 5 | SELECT again to look at the result | Check the change is what you meant, not just the right size |
| 6 | COMMIT if everything matches. Otherwise ROLLBACK | The only step that makes it real |
The undo script matters because a transaction only protects you until you commit. A mistake you notice an hour later, such as “those orders were paid after all”, needs its own way back. Writing it before the change means you’re not inventing it under pressure.
Reaching a database in a private subnet
QuickCart’s database lives in a private subnet with no public address, as the RDS lesson set up. Nothing on the internet can reach it, and that includes your laptop. You get there through something inside the VPC that can.
The modern way is Session Manager port forwarding. You pick an EC2 instance in the VPC that is allowed to reach the database, such as one of the app servers. Session Manager opens a tunnel from a port on your laptop, through that instance, to the database. Your laptop’s SQL client connects to 127.0.0.1 and ends up talking to the database.
| Piece | Why it’s needed |
|---|---|
An instance in the VPC with the SSM agent and the AmazonSSMManagedInstanceCore role policy | Session Manager needs something inside the VPC to carry the connection. Servers on EC2 sets this up |
The database’s security group allows port 3306 from that instance’s security group | The tunnel’s last hop is from the instance to the database. Security groups explains the chain |
| The AWS CLI and the Session Manager plugin on your laptop | The console can open a shell on an instance, but it has no way to forward a port. This is one place where you have to use the CLI |
There’s no inbound port open anywhere, no SSH key to lose, and every session is recorded against your AWS login. The older way was a bastion: a server in a public subnet with SSH open to the world, used as a stepping stone. Session Manager replaces it.
The command to open the tunnel is below. It uses three values. INSTANCE_ID is the instance’s ID from EC2 → Instances, and DB_ENDPOINT is the database’s Endpoint from RDS → Databases → your database → Connectivity & security.
aws ssm start-session --target INSTANCE_ID --document-name AWS-StartPortForwardingSessionToRemoteHost --parameters '{"host":["DB_ENDPOINT"],"portNumber":["3306"],"localPortNumber":["3306"]}'| Part | What it means |
|---|---|
aws ssm start-session | open a Session Manager session |
--target INSTANCE_ID | the instance inside the VPC that carries the connection |
--document-name AWS-StartPortForwardingSessionToRemoteHost | the kind of session: forward a port to another host, not to the instance itself |
--parameters '…' | the settings for that kind of session, written as JSON inside single quotes, so the shell passes it through untouched |
"host":["DB_ENDPOINT"] | where the instance should forward to: the database |
"portNumber":["3306"] | the port on the database. 3306 is MySQL’s |
"localPortNumber":["3306"] | the port to open on your laptop. Use another, like 13306, if you already run MySQL locally |
Starting session with SessionId: maya-0a1b2c3d4e5f67890
Port 3306 opened for sessionId maya-0a1b2c3d4e5f67890.
Waiting for connections...Your session ID will differ. Leave this terminal open: the tunnel lasts as long as the command runs. In a second terminal, connect through it:
mysql -h 127.0.0.1 -P 3306 -u admin -p quickcart| Part | What it means |
|---|---|
mysql | the MySQL command-line client |
-h 127.0.0.1 | connect to your own laptop, where the tunnel is listening |
-P 3306 | on this port, the localPortNumber from the tunnel. Capital P |
-u admin | as this database user, the master username you chose for RDS |
-p | ask for the password. Get it from Secrets Manager; Secrets shows how |
quickcart | start in this database |
Part 3: See it
Below are QuickCart’s orders. The left side is your session; the right is what everyone else sees, including the app. Three orders are pending and need cancelling.
First, run the UPDATE with no WHERE without starting a transaction, and try to roll it back. Then choose Start again, open a transaction, and do the same thing.
mysql> -- autocommit is on, as it is by default
| id | customer | status | total |
|---|---|---|---|
| 1001 | 41 | pending | 18.50 |
| 1002 | 17 | paid | 22.00 |
| 1003 | 52 | pending | 9.75 |
| 1004 | 8 | delivered | 31.20 |
| 1005 | 23 | paid | 14.00 |
| 1006 | 64 | pending | 27.40 |
| 1007 | 30 | delivered | 12.60 |
| 1008 | 12 | paid | 19.90 |
| id | customer | status | total |
|---|---|---|---|
| 1001 | 41 | pending | 18.50 |
| 1002 | 17 | paid | 22.00 |
| 1003 | 52 | pending | 9.75 |
| 1004 | 8 | delivered | 31.20 |
| 1005 | 23 | paid | 14.00 |
| 1006 | 64 | pending | 27.40 |
| 1007 | 30 | delivered | 12.60 |
| 1008 | 12 | paid | 19.90 |
pending and need cancelling. Start with START TRANSACTION, or see what happens without it.What to notice: the two mistakes were the same statement. Without a transaction, the right-hand side changed instantly and nothing could bring it back. Inside one, only your side changed, and ROLLBACK made it as if it had never happened. The row count, 8 where you expected 3, was the warning both times.
Part 4: Do it
Free — the lab runs MySQL in Docker on your own laptop. If you use an RDS database instead, that costs money while it runs.
You’ll set up a small orders table, save the rows you’re about to change, practise the no-WHERE mistake safely, watch a second session not see it, make the real change, and then undo it with your undo script. About 30 minutes.
You need Docker running on your laptop. Set up your computer covers installing it.
Start MySQL in a container
Terminaldocker run --name quickcart-mysql -e MYSQL_ROOT_PASSWORD=practice-only -p 3306:3306 -d mysql:8.4Part What it means docker runstart a new container from an image --name quickcart-mysqlcall the container this, so later commands can refer to it -e MYSQL_ROOT_PASSWORD=practice-onlyset an environment variable that the MySQL image reads on first start, to set the rootpassword. This password is for local practice only. Never put a real one on a command line-p 3306:3306make port 3306 inside the container reachable on port 3306 of your laptop -drun it in the background mysql:8.4the official MySQL image, version 8.4 You should seeThe first time, Docker downloads the image, which takes a minute. Then it prints one long line of letters and numbers: the new container’s ID.
MySQL takes about 20 seconds to get ready the first time. Check it’s up:
Terminaldocker logs quickcart-mysqlPart What it means docker logsprint what a container has written to its output quickcart-mysqlthe container’s name You should seeNear the end, a line containing
ready for connectionsandport: 3306. If it isn’t there yet, wait a few seconds and run the command again.Open two sessions
Open two terminal windows. In each one, run:
Terminaldocker exec -it quickcart-mysql mysql -u root -pPart What it means docker execrun a command inside a container that’s already running -itconnect your keyboard and screen to it, so you can type quickcart-mysqlwhich container mysql -u root -pthe MySQL client, as the rootuser, asking for the passwordType
practice-onlywhen it asks for the password. Nothing shows as you type.You should seeEnter password: Welcome to the MySQL monitor. Commands end with ; or \g.A few more lines follow, then the
mysql>prompt. Call them window 1 and window 2. Window 2 plays QuickCart’s app: it only ever reads.Create the orders table
In window 1, paste this. Each statement ends with a semicolon, and the client runs them one after another:
SQLCREATE DATABASE quickcart; USE quickcart; CREATE TABLE orders ( id INT PRIMARY KEY, customer_id INT NOT NULL, status VARCHAR(20) NOT NULL, total DECIMAL(8, 2) NOT NULL ); INSERT INTO orders (id, customer_id, status, total) VALUES (1001, 41, 'pending', 18.50), (1002, 17, 'paid', 22.00), (1003, 52, 'pending', 9.75), (1004, 8, 'delivered', 31.20), (1005, 23, 'paid', 14.00), (1006, 64, 'pending', 27.40), (1007, 30, 'delivered', 12.60), (1008, 12, 'paid', 19.90);Part What it means CREATE DATABASE quickcartmake an empty database USE quickcartrun everything after this inside it CREATE TABLE ordersmake a table with four columns id INT PRIMARY KEYa whole number that’s unique for every order status VARCHAR(20)text up to 20 characters: pending,paidand so onDECIMAL(8, 2)a number with exactly two decimal places. Money is never stored as a floating-point number INSERT INTO … VALUESadd eight rows The last line of output should be:
You should seeQuery OK, 8 rows affected (0.01 sec) Records: 8 Duplicates: 0 Warnings: 0Timings like
(0.01 sec)will differ on your machine throughout this lab.In window 2, choose the same database:
SQLUSE quickcart;You should seeDatabase changedSave the rows, and write the undo script first
The job is to cancel the three pending orders. Before anything else, in window 1, keep a copy of exactly the rows you’re about to change, with their current values:
SQLCREATE TABLE orders_before_fix AS SELECT id, status FROM orders WHERE status = 'pending';Part What it means CREATE TABLE orders_before_fix ASmake a new table from the result of a query SELECT id, status FROM ordersthe ID and the current status of each row WHERE status = 'pending'the same condition the real change will use You should seeQuery OK, 3 rows affected (0.02 sec) Records: 3 Duplicates: 0 Warnings: 0This comes before the transaction on purpose:
CREATE TABLEis DDL, and it would commit anything already in a transaction.Now write the undo script, and don’t run it yet. Keep it in a text file next to your notes:
SQLUPDATE orders JOIN orders_before_fix ON orders_before_fix.id = orders.id SET orders.status = orders_before_fix.status;Part What it means UPDATE orderschange rows in the orders table JOIN orders_before_fix ON … id = orders.idonly the rows whose ID is in the saved copy SET orders.status = orders_before_fix.statusput each one’s saved status back Count the rows with the same WHERE
SQLSELECT id, status FROM orders WHERE status = 'pending';You should see+------+---------+ | id | status | +------+---------+ | 1001 | pending | | 1003 | pending | | 1006 | pending | +------+---------+ 3 rows in set (0.00 sec)Three. That’s the number the
UPDATEmust report. Anything else means stop.Make the mistake, safely
Now do what Noor did, but inside a transaction. In window 1:
SQLSTART TRANSACTION;You should seeQuery OK, 0 rows affected (0.00 sec)Then the
UPDATEwith itsWHEREmissing:SQLUPDATE orders SET status = 'cancelled';You should seeQuery OK, 8 rows affected (0.00 sec) Rows matched: 8 Changed: 8 Warnings: 08, not 3. That’s the moment to stop. First, look at what window 1 sees:
SQLSELECT status, COUNT(*) FROM orders GROUP BY status;Part What it means SELECT status, COUNT(*)each status, and how many rows have it GROUP BY statusone line per status, instead of one per row You should see+-----------+----------+ | status | COUNT(*) | +-----------+----------+ | cancelled | 8 | +-----------+----------+ 1 row in set (0.00 sec)See that nobody else can see it
In window 2, run the same query:
SQLSELECT status, COUNT(*) FROM orders GROUP BY status;You should see+-----------+----------+ | status | COUNT(*) | +-----------+----------+ | pending | 3 | | paid | 3 | | delivered | 2 | +-----------+----------+ 3 rows in set (0.00 sec)The rows may come out in a different order. Window 2 still sees the real data. If this were production, customers would have noticed nothing.
Back in window 1, undo it:
SQLROLLBACK;You should seeQuery OK, 0 rows affected (0.00 sec)Run the count again in window 1. It now matches window 2: 3 pending, 3 paid, 2 delivered.
Make the real change
In window 1, the whole routine, one statement at a time:
SQLSTART TRANSACTION;SQLUPDATE orders SET status = 'cancelled' WHERE status = 'pending';You should seeQuery OK, 3 rows affected (0.00 sec) Rows matched: 3 Changed: 3 Warnings: 0Rows matched: 3equals the count from yourSELECT. Look at the result before committing:SQLSELECT id, status FROM orders WHERE id IN (1001, 1003, 1006);You should see+------+-----------+ | id | status | +------+-----------+ | 1001 | cancelled | | 1003 | cancelled | | 1006 | cancelled | +------+-----------+ 3 rows in set (0.00 sec)Both checks pass, so commit:
SQLCOMMIT;You should seeQuery OK, 0 rows affected (0.00 sec)Run the count in window 2 again. Now it shows 3 cancelled, 3 paid and 2 delivered. Only
COMMITmade the change visible.Run the undo script
Support calls back: the payment provider has recovered, and those three customers were charged after all. The orders need to go back to how they were. Because you wrote the undo script first, this takes a minute. In window 1:
SQLSTART TRANSACTION;SQLUPDATE orders JOIN orders_before_fix ON orders_before_fix.id = orders.id SET orders.status = orders_before_fix.status;You should seeQuery OK, 3 rows affected (0.00 sec) Rows matched: 3 Changed: 3 Warnings: 0Three again, the size of the saved copy. Check, then commit:
SQLSELECT id, status FROM orders WHERE id IN (1001, 1003, 1006);You should see+------+---------+ | id | status | +------+---------+ | 1001 | pending | | 1003 | pending | | 1006 | pending | +------+---------+ 3 rows in set (0.00 sec)SQLCOMMIT;The undo script runs inside a transaction too. It’s a change to production like any other.
See DDL commit behind your back
This step shows the rule people learn the hard way. In window 1:
SQLSTART TRANSACTION;SQLUPDATE orders SET total = 0 WHERE id = 1002;You should seeQuery OK, 1 row affected (0.00 sec) Rows matched: 1 Changed: 1 Warnings: 0Now a
CREATE TABLE, then roll back:SQLCREATE TABLE notes (id INT);SQLROLLBACK;Check the order you changed:
SQLSELECT id, total FROM orders WHERE id = 1002;You should see+------+-------+ | id | total | +------+-------+ | 1002 | 0.00 | +------+-------+ 1 row in set (0.00 sec)The rollback didn’t undo it. The
CREATE TABLEcommitted theUPDATEbefore it ran, andROLLBACKhad nothing left to undo. Put the right value back:SQLUPDATE orders SET total = 22.00 WHERE id = 1002;You should seeQuery OK, 1 row affected (0.00 sec) Rows matched: 1 Changed: 1 Warnings: 0There was no
START TRANSACTIONbefore that one, so autocommit saved it straight away.Clean-up step: Clean up
Type
exitin both windows. Then remove the container and everything in it:Terminaldocker rm -f quickcart-mysqlPart What it means docker rmdelete a container -fstop it first if it’s running quickcart-mysqlwhich container You should seequickcart-mysqlThe database lived inside the container, so it’s gone too. If you also want the downloaded image back off your disk:
Terminaldocker image rm mysql:8.4Part What it means docker image rmdelete a downloaded image mysql:8.4which one If you used RDS instead: in window 1, run
DROP TABLE orders_before_fix;andDROP TABLE notes;, stop the tunnel with Ctrl+C, and delete the database by following the clean-up in Managed databases: RDS. It costs money until you do.
Part 5: Check yourself
You need to cancel the orders stuck as pending. What do you run first?
Inside a transaction, your UPDATE reports "48112 rows affected". You expected 3. What now?
Which of these can ROLLBACK undo?
You've run an UPDATE inside a transaction, but not committed. What does the QuickCart app see?
The database is in a private subnet with no public address. How do you run SQL against it from your laptop?
Part 6: Cheat sheet
| Step or statement | What it’s for |
|---|---|
CREATE TABLE x_before_fix AS SELECT id, col FROM t WHERE … | Save the rows you’ll change. Before the transaction |
| Undo script | Written before the change: UPDATE t JOIN x_before_fix ON … SET … |
SELECT … WHERE (same WHERE) | Count the rows first |
START TRANSACTION or BEGIN | Nothing after this is permanent until you commit |
Rows matched: N | Must equal your count. If not, ROLLBACK |
COMMIT | Makes it permanent and visible to everyone |
ROLLBACK | Throws the whole transaction away |
| Autocommit | On by default. A statement outside a transaction is permanent at once |
DDL: CREATE, DROP, TRUNCATE, ALTER | Commits at once, and commits your earlier changes. Can’t be rolled back |
ERROR 1205 … Lock wait timeout | Someone left a transaction open. Keep yours short |
| Private database | aws ssm start-session with AWS-StartPortForwardingSessionToRemoteHost, then mysql -h 127.0.0.1 |