Laptop to Production

Running SQL safely

Transactions, checking before committing, undo scripts.

Time
40 minutes
Level
Intermediate

Part 1: The problem

QuickCart, a Friday afternoon

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:

SQL
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.

StatementWhat it does
START TRANSACTIONOpens a transaction. BEGIN does the same in the mysql client
COMMITMakes every change in the transaction permanent and visible to everyone
ROLLBACKThrows away every change in the transaction, as if it never ran
Key point: Never change data you haven’t counted: SELECT with the same WHERE first, change it inside a transaction, and only COMMIT when the numbers match.

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 runWith autocommit on (the default)
UPDATE orders SET … on its ownPermanent 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.

StatementInside a transaction
INSERT, UPDATE, DELETECan be rolled back
CREATE TABLE, DROP TABLECommits at once, and commits your earlier changes too
TRUNCATE TABLECommits at once. Every row is gone for good
ALTER TABLE, RENAME TABLECommits 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

#StepWhy
1Save the rows you’re about to change, and write the undo scriptIf 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
2SELECT with exactly the same WHEREGives you the number of rows the change should touch
3START TRANSACTIONFrom here on, nothing is permanent
4Run the UPDATE or DELETERead Rows matched. It must equal the count from step 2
5SELECT again to look at the resultCheck the change is what you meant, not just the right size
6COMMIT if everything matches. Otherwise ROLLBACKThe 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.

PieceWhy it’s needed
An instance in the VPC with the SSM agent and the AmazonSSMManagedInstanceCore role policySession 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 groupThe 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 laptopThe 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.

Terminal
aws ssm start-session --target INSTANCE_ID --document-name AWS-StartPortForwardingSessionToRemoteHost --parameters '{"host":["DB_ENDPOINT"],"portNumber":["3306"],"localPortNumber":["3306"]}'
PartWhat it means
aws ssm start-sessionopen a Session Manager session
--target INSTANCE_IDthe instance inside the VPC that carries the connection
--document-name AWS-StartPortForwardingSessionToRemoteHostthe 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
You should see
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:

Terminal
mysql -h 127.0.0.1 -P 3306 -u admin -p quickcart
PartWhat it means
mysqlthe MySQL command-line client
-h 127.0.0.1connect to your own laptop, where the tunnel is listening
-P 3306on this port, the localPortNumber from the tunnel. Capital P
-u adminas this database user, the master username you chose for RDS
-pask for the password. Get it from Secrets Manager; Secrets shows how
quickcartstart 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.

Run a statement
mysql> -- autocommit is on, as it is by default
Your sessionAutocommit: every statement is permanent
The orders table as your session sees it
idcustomerstatustotal
100141pending18.50
100217paid22.00
100352pending9.75
10048delivered31.20
100523paid14.00
100664pending27.40
100730delivered12.60
100812paid19.90

 

Everyone elseThe app, other people
The orders table as every other session sees it
idcustomerstatustotal
100141pending18.50
100217paid22.00
100352pending9.75
10048delivered31.20
100523paid14.00
100664pending27.40
100730delivered12.60
100812paid19.90

 

ReadyThree orders are stuck as 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.

  1. Start MySQL in a container

    Terminal
    docker run --name quickcart-mysql -e MYSQL_ROOT_PASSWORD=practice-only -p 3306:3306 -d mysql:8.4
    PartWhat 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 root password. 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 see

    The 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:

    Terminal
    docker logs quickcart-mysql
    PartWhat it means
    docker logsprint what a container has written to its output
    quickcart-mysqlthe container’s name
    You should see

    Near the end, a line containing ready for connections and port: 3306. If it isn’t there yet, wait a few seconds and run the command again.

  2. Open two sessions

    Open two terminal windows. In each one, run:

    Terminal
    docker exec -it quickcart-mysql mysql -u root -p
    PartWhat 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 root user, asking for the password

    Type practice-only when it asks for the password. Nothing shows as you type.

    You should see
    Enter 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.

  3. Create the orders table

    In window 1, paste this. Each statement ends with a semicolon, and the client runs them one after another:

    SQL
    CREATE 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);
    PartWhat 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, paid and so on
    DECIMAL(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 see
    Query OK, 8 rows affected (0.01 sec)
    Records: 8  Duplicates: 0  Warnings: 0

    Timings like (0.01 sec) will differ on your machine throughout this lab.

    In window 2, choose the same database:

    SQL
    USE quickcart;
    You should see
    Database changed
  4. Save 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:

    SQL
    CREATE TABLE orders_before_fix AS
    SELECT id, status FROM orders WHERE status = 'pending';
    PartWhat 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 see
    Query OK, 3 rows affected (0.02 sec)
    Records: 3  Duplicates: 0  Warnings: 0

    This comes before the transaction on purpose: CREATE TABLE is 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:

    SQL
    UPDATE orders
    JOIN orders_before_fix ON orders_before_fix.id = orders.id
    SET orders.status = orders_before_fix.status;
    PartWhat 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
  5. Count the rows with the same WHERE

    SQL
    SELECT 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 UPDATE must report. Anything else means stop.

  6. Make the mistake, safely

    Now do what Noor did, but inside a transaction. In window 1:

    SQL
    START TRANSACTION;
    You should see
    Query OK, 0 rows affected (0.00 sec)

    Then the UPDATE with its WHERE missing:

    SQL
    UPDATE orders SET status = 'cancelled';
    You should see
    Query OK, 8 rows affected (0.00 sec)
    Rows matched: 8  Changed: 8  Warnings: 0

    8, not 3. That’s the moment to stop. First, look at what window 1 sees:

    SQL
    SELECT status, COUNT(*) FROM orders GROUP BY status;
    PartWhat 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)
  7. See that nobody else can see it

    In window 2, run the same query:

    SQL
    SELECT 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:

    SQL
    ROLLBACK;
    You should see
    Query 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.

  8. Make the real change

    In window 1, the whole routine, one statement at a time:

    SQL
    START TRANSACTION;
    SQL
    UPDATE orders SET status = 'cancelled' WHERE status = 'pending';
    You should see
    Query OK, 3 rows affected (0.00 sec)
    Rows matched: 3  Changed: 3  Warnings: 0

    Rows matched: 3 equals the count from your SELECT. Look at the result before committing:

    SQL
    SELECT 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:

    SQL
    COMMIT;
    You should see
    Query 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 COMMIT made the change visible.

  9. 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:

    SQL
    START TRANSACTION;
    SQL
    UPDATE orders
    JOIN orders_before_fix ON orders_before_fix.id = orders.id
    SET orders.status = orders_before_fix.status;
    You should see
    Query OK, 3 rows affected (0.00 sec)
    Rows matched: 3  Changed: 3  Warnings: 0

    Three again, the size of the saved copy. Check, then commit:

    SQL
    SELECT 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)
    SQL
    COMMIT;

    The undo script runs inside a transaction too. It’s a change to production like any other.

  10. See DDL commit behind your back

    This step shows the rule people learn the hard way. In window 1:

    SQL
    START TRANSACTION;
    SQL
    UPDATE orders SET total = 0 WHERE id = 1002;
    You should see
    Query OK, 1 row affected (0.00 sec)
    Rows matched: 1  Changed: 1  Warnings: 0

    Now a CREATE TABLE, then roll back:

    SQL
    CREATE TABLE notes (id INT);
    SQL
    ROLLBACK;

    Check the order you changed:

    SQL
    SELECT 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 TABLE committed the UPDATE before it ran, and ROLLBACK had nothing left to undo. Put the right value back:

    SQL
    UPDATE orders SET total = 22.00 WHERE id = 1002;
    You should see
    Query OK, 1 row affected (0.00 sec)
    Rows matched: 1  Changed: 1  Warnings: 0

    There was no START TRANSACTION before that one, so autocommit saved it straight away.

  11. Clean-up step: Clean up

    Type exit in both windows. Then remove the container and everything in it:

    Terminal
    docker rm -f quickcart-mysql
    PartWhat it means
    docker rmdelete a container
    -fstop it first if it’s running
    quickcart-mysqlwhich container
    You should see
    quickcart-mysql

    The database lived inside the container, so it’s gone too. If you also want the downloaded image back off your disk:

    Terminal
    docker image rm mysql:8.4
    PartWhat 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; and DROP 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

On one card
Step or statementWhat 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 scriptWritten before the change: UPDATE t JOIN x_before_fix ON … SET …
SELECT … WHERE (same WHERE)Count the rows first
START TRANSACTION or BEGINNothing after this is permanent until you commit
Rows matched: NMust equal your count. If not, ROLLBACK
COMMITMakes it permanent and visible to everyone
ROLLBACKThrows the whole transaction away
AutocommitOn by default. A statement outside a transaction is permanent at once
DDL: CREATE, DROP, TRUNCATE, ALTERCommits at once, and commits your earlier changes. Can’t be rolled back
ERROR 1205 … Lock wait timeoutSomeone left a transaction open. Keep yours short
Private databaseaws ssm start-session with AWS-StartPortForwardingSessionToRemoteHost, then mysql -h 127.0.0.1

Finished the build and the quiz? Mark it done to fill in its stop on the route map.