AWS Builder Center

SQL and Relational Databases: Zero to Cloud on Amazon RDS

A hands-on guide to SQL and relational databases. Create tables, write queries, and learn how Amazon RDS runs your database in the cloud.

Developer Relations Engineer

A Hands-On Introduction to Relational Databases, From First Table to Cloud

A hands-on guide to understanding databases, from "what even is a table" to running one in the cloud. Bring your laptop. And maybe some snacks.
Note: If you want a video to follow along with this blog, you can find it in the AWS Developers Youtube Channel here: SQL Database Full Course 

In 1970, a mathematician at IBM named Edgar Codd published a paper about organizing data into tables with rows and columns. It was dense, theoretical, and not a hit at parties. Fifty-six years later, that paper is the reason your phone remembers your password, your bank knows your balance, and your favorite app knows what you were browsing last Tuesday.
Every piece of software you've touched today, the alarm that woke you up, the app you scrolled through over breakfast, the coffee order you placed, canceled, and re-placed because you changed your mind, all of it created data. Tiny digital breadcrumbs scattered across the internet. And something, somewhere, caught every crumb and filed it away with the organizational discipline of a librarian who enjoys alphabetizing.
That something is a database. By the end of this post, you'll know how to create one from scratch, design the tables behind a social media app, and write queries that would make a hiring manager nod approvingly. We'll alternate between concepts and hands-on work: read a section, build a thing, repeat until you're dangerous.

Your data has to live somewhere (and "my laptop" isn't a long-term plan)

Picture this: you sign up for a new app. Username, email, password, the whole ritual. You hit "Create Account" and feel that brief dopamine hit of joining something new. What happened behind the scenes? Your data had to be written down somewhere permanent, so that tomorrow, when you log back in, the app doesn't stare at you blankly like a goldfish meeting you for the first time.
That permanence is called persistence. Without it, every app would forget everything the moment you close it. You sign up, close the app, reopen it, and it's gone. Account? Never heard of it.
Now think about what happens after login. Your feed loads. Posts, timestamps, like counts, all appearing on screen. It's not magic; it's a database doing a bunch of reads fast while you sit there judging people's vacation photos.
We've already got two operations happening: the app wrote your account data when you signed up, and it read a bunch of data to load your feed. There's more. You update your bio because your old one was embarrassing. That's operation three. You delete a post you made at 2 AM that seemed profound at the time but was unhinged. Operation four.
Create. Read. Update. Delete.
We call these CRUD operations, which is fitting because your data goes through a lot. Every interaction you have with an app maps to one of these four, and every one depends on that data living somewhere reliable.
That somewhere? A database. We're going to talk about relational databases, the kind that power most of the software on the planet. The word "relational" is doing serious heavy lifting in that sentence, and by the end of this post, you'll understand these database relationships better than most people understand their actual ones.

Tables, rows, and columns (a.k.a. spreadsheets with standards)

If you've ever opened a spreadsheet and thought "I am a data professional," congratulations. You already understand the basic shape of a database table. Rows and columns. That's the whole visual.
When someone creates an account on your app, their info lands in a table. Each row is one user. Each column is one attribute: username, email, password, the date they signed up. Think of it like a boring yearbook where everyone's entry follows the exact same format.
What that looks like:
Users Table diagram
Every row gets a unique identifier called a primary key. It's like a serial number, automatically assigned, never repeated, and indifferent to your feelings. Row 1 is Row 1 forever. No take-backs.
Each column also has a strict data type: text, numbers, dates, booleans (true/false values). You define these when you create the table, and the database enforces them like a strict bouncer at the door. Try to shove a paragraph into a column that expects a number and the database will tell you to get lost.
One table. Clean structure. Rigid rules. Time to build one.

🔧 Hands-On: Setting up your first database

Enough theory. We'll use MySQL, one of the most widely used relational database systems on the planet. MySQL speaks SQL (Structured Query Language), which is the language you use to talk to databases.
> New to SQL? SQL stands for Structured Query Language. It's the standard way to create, read, update, and delete data in relational databases. You can find a full reference at dev.mysql.com/doc/refman .

Step 1: Install MySQL

Option A: Installer (Windows or macOS)
Head to mysql.com/downloads  and grab the Community Server for your OS. During installation, it'll ask you to set a root password. Pick something memorable. This is the master key to your entire database. Lose it and you'll be Googling "MySQL password reset" at midnight like the rest of us have at some point.
Option B: Homebrew (macOS)
If you're on a Mac, Homebrew is the faster path. Homebrew is a package manager for macOS that lets you install software from the command line instead of hunting down installers and dragging things into folders.
If you don't have Homebrew yet, open your terminal and paste this:
1
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
It'll ask for your password and take a minute to set up. Once that's done, installing MySQL is one line:
1
brew install mysql
Homebrew downloads MySQL, installs it, and sets everything up. Once it's done, start the MySQL server:
1
brew services start mysql
That runs MySQL in the background so it's ready whenever you need it. Now secure it with a root password:
1
mysql_secure_installation
This walks you through setting a root password, the master password for your database. Pick something you'll remember. It'll also ask about removing test databases and anonymous users. Say yes to all of that.

Step 2: Install MySQL Workbench

Same site, different download. Workbench is the visual tool where you'll write and run queries. A query is a command you send to the database: "make this table," "show me that data," "please delete my mistakes."

Step 3: Connect

Open Workbench. You'll see a + symbol next to "MySQL connections." Click it, enter your root password. You're now talking to a database server running on your machine.
> Troubleshooting tips: If you get a connection error, make sure the MySQL server is actually running (check your system services).

Step 4: Create a database and your first table

You will see the SQL editor where you can enter SQL Queries. You can enter the commands seen in this blog into the editor and then hit the left thunderbolt icon at the top toolbar to run the sql query.
1
2
3
4
5
-- Create a database to hold our tables
CREATE DATABASE my_app;

-- Tell MySQL we want to use it
USE my_app;
The most common beginner mistakes are forgetting a semicolon at the end of a statement, or not selecting the right database with USE.
You should then choose the "Schemas" tab and in there you will see a refresh icon (circular arrows as seen in image below). Hit that so it updates the view with your new database you created.
After choosing the refresh icon (circular arrows on left panel next to "Schemas" header, you should see the database show up like in the following image:
Now create a Users table by clearing out the query in the editor, and replacing it with the following.
1
2
3
4
5
6
7
CREATE TABLE users (
id INT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(100) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
Once you enter in that query, remember to press the thunderbolt icon again to execute the query! You will not see the table yet until you hit the refresh icon again.
What all that jargon means:
KeywordTranslation
INTInteger, a whole number
VARCHAR(50)Variable-length text, up to 50 characters
NOT NULLRequired field, no blanks allowed
UNIQUENo duplicates, every value must be one-of-a-kind
AUTO_INCREMENTThe database picks the next number for you
TIMESTAMPStores date and time
DEFAULT CURRENT_TIMESTAMPAuto-fills with "right now" if you leave it blank
Here is a visual to see the concepts covered so far regarding how "Create Table" command works:

Step 5: Insert some users

In the wild, someone fills out a registration form, hits submit, and the backend takes that data and builds a query that looks like the following. Run this query to create, or insert a new row into the users table.
1
2
INSERT INTO users (username, email, password)
VALUES ('mateo_jackson', 'mateo@example.com', 'hashed_password_here');
We didn't provide an id or created_at. The database handles those automatically. Verify:
1
SELECT * FROM users;
You should see something like the following at the bottom half of your SQL Workbench.
1
2
3
4
5
+----+---------------+-------------------+----------------------+---------------------+
| id | username | email | password | created_at |
+----+---------------+-------------------+----------------------+---------------------+
| 1 | mateo_jackson | mateo@example.com | hashed_password_here | 2024-01-15 10:30:00 |
+----+---------------+-------------------+----------------------+---------------------+
One row. Five columns. Two of them filled in without you lifting a finger. Add more people:
1
2
3
INSERT INTO users (username, email, password)
VALUES ('mary_major', 'mary@example.com', 'hashed_password_here'),
('paulo_santos', 'paulo@example.com', 'hashed_password_here');
Run SELECT * FROM users; again. Three users. Three unique IDs. Zero effort on the ID front.
You created a database, built a table, and inserted data. That's the same pipeline that fires when someone signs up for any app on the planet. Form → Backend → Query → Database. That's the whole flow.

Constraints: The bouncers your data needs

That went smoothly. Suspiciously smoothly. Because we haven't accounted for the one variable that ruins every elegant system: people.
Without guardrails, someone will enter their age as 9999, their email as "lol no," and their username as a single emoji. People are chaos agents, and your database needs protection.
That's what constraints are for. We already used a few: NOT NULL (you must fill this in), UNIQUE (no copycats), DEFAULT (here's a sensible fallback). There's a heavier hitter: the CHECK constraint. It lets you define custom rules, like an age column that can't go negative, a rating that must be between 1 and 5, or a quantity that can't exceed what's in stock.
Think of constraints as the velvet rope at a club. They don't care who you are. If your data doesn't meet the criteria, it's not getting in.
> A note on validation: In practice, your application code (the backend) validates user input before it reaches the database, checking formats, sanitizing strings, rejecting obvious nonsense. Database constraints act as a safety net underneath. The app catches most bad data at the door, and the database catches anything that slips through.

🔧 Hands-On: Adding constraints

Add an age column with a reality check (literally):
1
ALTER TABLE users ADD COLUMN age INT CHECK (age >= 0 AND age <= 150);
Now try to sneak in some nonsense:
1
2
3
-- This will fail, and that's exactly what we want
INSERT INTO users (username, email, password, age)
VALUES ('chaos_agent', 'chaos@example.com', 'hashed_pw', -5);
Rejected. The bouncer did its job. Negative five years old? Not on this database's watch. Now try a reasonable human age:
1
2
INSERT INTO users (username, email, password, age)
VALUES ('valid_user', 'valid@example.com', 'hashed_pw', 25);
Sails right through. Constraints let you encode the rules of reality into your table structure so your application code doesn't have to play whack-a-mole with every edge case users dream up.

🔧 Hands-On: The other half of CRUD

We promised four operations. We've done Create (INSERT) and Read (SELECT). Time to finish the job.
Update mateo_jackson's username:
1
UPDATE users SET username = 'mateo_updated' WHERE id = 1;
One row changed. That WHERE clause is load-bearing. Remove it and you've renamed every user in the table to the same thing. That's not a feature request. That's a résumé-generating event.
Now delete a regrettable post:
1
DELETE FROM posts WHERE id = 3 AND user_id = 1;
Gone forever. The AND user_id = 1 is a safety net, making sure you only delete your post. In production, the backend enforces this so users can't delete other people's content.
Reset mateo_jackson for the rest of our examples:
1
UPDATE users SET username = 'mateo_jackson' WHERE id = 1;
That's the full CRUD cycle. Create with INSERT, Read with SELECT, Update with UPDATE, Delete with DELETE. Every feature in every app you've used maps to one of these four operations.

The one-big-table problem (a.k.a. the kitchen junk drawer of data)

Our Users table is looking pristine. Constraints in place, data types locked down, everything humming along. You might be tempted to think databases are easy. That's when databases punish you for your hubris.
Databases are a lot like relationships. Everything's great when it's simple. The moment you start adding complexity, a shipping address AND a billing address, things get messy fast.
Right now our table is clean and minimal. We're about to watch it make some regrettable life choices.
We've got username, email, password. Lean and focused. But now the product team wants to store addresses. Street, city, state, zip. "Just add more columns," someone says. And sure, that works. Until you realize that every time someone loads a profile page, the database hauls that address data along for the ride, even though the profile page doesn't display it.
Then the requirements evolve. Now you need a billing address AND a shipping address. You're adding street_billing, city_billing, state_billing, zip_billing, street_shipping... Your once-elegant Users table is metastasizing into a junk drawer. You know the one. Every kitchen has one. It started with a few batteries and a takeout menu and now it contains three Allen wrenches, a birthday candle, and what might be a AAA battery or might be a small piece of candy. Don't let your database become that drawer.
Now imagine someone suggests storing posts in the same table. Username, email, street, post_1_caption, post_1_image, post_2_caption... A user with 500 posts means 1,500 extra columns. And since most users have maybe 3 posts, you've got 1,497 empty columns per row, a vast digital wasteland of NULLs sitting there like empty seats at a concert nobody wanted to attend.
So what's the fix? Keep reading!

Normalization

The antidote to the junk drawer has a name: normalization. The core idea is aggressive in its simplicity. Every piece of data should live in exactly one place. No copies. No duplicates. One source of truth.
Instead of cramming everything into one mega-table, you split your data into focused tables. Users go in one table. Addresses go in another. Posts in a third. Then you connect them using a foreign key, a column in one table that points to the primary key of another table. The Addresses table gets a user_id column that says "this address belongs to User #42." The Posts table gets the same treatment.
Now when someone changes their display name? One update. One row. One table. Every foreign key still points to the right place. No hunting through 500 rows. No mismatches.

🔧 Hands-On: Building related tables

Create an Addresses table. This is a one-to-one relationship: one user, one address.
> Why a separate table? If every user has exactly one address, you could keep it in the Users table. We're splitting it out here to show how one-to-one relationships work with foreign keys, and because in real apps, addresses often become optional or multiple over time. Separating early saves you a painful migration later.
1
2
3
4
5
6
7
8
9
CREATE TABLE addresses (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL UNIQUE,
street VARCHAR(255) NOT NULL,
city VARCHAR(100) NOT NULL,
state VARCHAR(50) NOT NULL,
zip_code VARCHAR(20) NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
);
Note: Always remember to click the thunderbolt icon to execute the query. If you are creating new table, you should also click the refresh icon so the new table shows up under the tables dropdown in the left panel.
That UNIQUE constraint on user_id is the enforcer here. Without it, nothing stops you from accidentally giving one user seven addresses. With it, the database draws a hard line: one address per customer, no exceptions, please form an orderly queue.
1
2
INSERT INTO addresses (user_id, street, city, state, zip_code)
VALUES (1, '123 Main St', 'Austin', 'TX', '78701');
Now build a Posts table. This is a one-to-many relationship: one user, many posts.
1
2
3
4
5
6
7
8
CREATE TABLE posts (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL,
caption TEXT,
image_url VARCHAR(500),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
Spot the difference: no UNIQUE on user_id this time. That's what makes it one-to-many. The same user_id can appear across multiple rows because one person can have 500 posts about their cat.
1
2
3
4
INSERT INTO posts (user_id, caption, image_url)
VALUES (1, 'My first post!', 'https://storage.example.com/img1.jpg'),
(1, 'Another day another post', 'https://storage.example.com/img2.jpg'),
(1, 'Three for three', 'https://storage.example.com/img3.jpg');
Run SELECT * FROM posts;.
Three posts, all linked back to User #1 through that foreign key. The user's name isn't duplicated anywhere. Their address isn't tangled up in the mix. Each table has one job and does it well. That's normalization: if it doesn't serve a clear purpose, it doesn't belong in that table.

Querying relationships: where your database earns its keep

Tables? Built. Foreign keys? Connected. Normalization? Achieved.
Now for the hard part. Your app needs data from multiple tables at once.
Imagine tapping on someone's profile in an app. Their name appears. Their bio loads. Their address shows up for shipping purposes. That information is scattered across two different tables: username lives in Users, address lives in Addresses. How does the app stitch it all together on one screen?
With a JOIN. A JOIN combines rows from multiple tables based on a shared column, in our case, the foreign key. You're telling the database: "Go grab this user's info from over here, and their address from over there, and hand me one neat package."
The most common type is an INNER JOIN. It only returns rows where both tables have matching data.
1
2
3
4
5
SELECT users.username, users.email,
addresses.street, addresses.city, addresses.state, addresses.zip_code
FROM users
INNER JOIN addresses ON users.id = addresses.user_id
WHERE users.id = 1;
Once you run the query above in your SQL Workbench editor, you should see results similar to the following:
Read that out loud and it's almost English: "Select these columns from users, join with addresses where the IDs match, and only give me user number one." SQL is readable once you stop being intimidated by the all-caps shouting.
That's a one-to-one query. One user, one address, one row back. This query fires when someone opens their shipping settings or hits "Edit Address" on a checkout page.
Now scroll down on that same profile. Posts start loading. That's a different relationship, one-to-many. One user, potentially hundreds of posts.
1
2
3
4
5
SELECT users.username, posts.caption, posts.image_url, posts.created_at
FROM users
INNER JOIN posts ON users.id = posts.user_id
WHERE users.id = 1
ORDER BY posts.created_at DESC;
Same query shape. Different relationship. The ORDER BY sorts posts newest-first. Your result should look like the following:
The SQL for one-to-one and one-to-many looks nearly identical. The difference isn't in how you write the query, it's in how many rows come back. One address row. Many post rows. The foreign key and table design handle the distinction. You write the JOIN and let the table structure do the thinking. Remember, INNER JOIN gives you the overlap between the tables where the left table has data that matches data on the right table's foreign key.

🔧 Hands-On: Running JOINs

We've got data in all three tables.
The profile page query, one-to-one. User plus address:
1
2
3
4
5
SELECT users.username, users.email,
addresses.street, addresses.city, addresses.state, addresses.zip_code
FROM users
INNER JOIN addresses ON users.id = addresses.user_id
WHERE users.id = 1;
One row. Username, email, full address, assembled from two separate tables into one clean result. This query powers every "Edit Address" page and every checkout screen that auto-fills your shipping info.
The profile feed query, one-to-many. User plus all their posts:
1
2
3
4
5
SELECT users.username, posts.caption, posts.image_url, posts.created_at
FROM users
INNER JOIN posts ON users.id = posts.user_id
WHERE users.id = 1
ORDER BY posts.created_at DESC;
Multiple rows back. The username repeats on every row because the JOIN pairs each post with its owner. That's expected behavior, not a bug. The app takes each row and renders it as a card in the feed.
Users who've never posted vanish with an INNER JOIN. No matching posts means no results. That's fine for a profile feed. But what if you're building an admin dashboard and need to see every user, including the lurkers who signed up six months ago and have contributed zero content?
Enter the LEFT JOIN:
1
2
3
4
5
SELECT users.username, COUNT(posts.id) AS post_count
FROM users
LEFT JOIN posts ON users.id = posts.user_id
GROUP BY users.username
ORDER BY post_count DESC;
A LEFT JOIN gives you everyone from the left table, and matches posts where they exist. No posts? You still show up with a count of zero.
INNER JOIN for app features where you need matching data. LEFT JOIN when you need the complete picture, ghosts included. That covers about 90% of the queries you'll write.

Many-to-many: the "it's complicated" of database relationships

One-to-one and one-to-many are the stable, predictable relationships. You ask them a question, you get a clean answer.
There's one more relationship type, and it's the one that makes junior developers stare at their screen for twenty minutes before asking an AI assistant for help.
You know that little heart button? The one you tap reflexively, sometimes on posts you haven't read? That tiny UI element is about to create a lot of database complexity.
When you like a post, you, one user, can like hundreds of posts. And each post can be liked by hundreds of users. That's not one-to-one or one-to-many. That's many-to-many.
The storage problem: where does a like live? You can't add a liked_posts column to the Users table. A single column can't hold hundreds of values. And you can't add a liked_by_users column to the Posts table for the same reason. You'd be right back in junk-drawer territory: liked_by_1, liked_by_2, liked_by_3... We already held a funeral for that approach.
The solution is a third table, a junction table. It doesn't represent a thing. It represents a connection between two things.
Run the following Query:
1
2
3
4
5
6
7
8
9
CREATE TABLE likes (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL,
post_id INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (post_id) REFERENCES posts(id),
UNIQUE(user_id, post_id)
);
Each row is one like. User #1 liked Post #3. User #2 liked Post #3. User #1 liked Post #7. That UNIQUE constraint on the combination of user_id and post_id means you can't like the same post twice. Double-tap all you want; the database has boundaries, even if you don't.
This pattern shows up everywhere:
  • Students and courses: one student enrolls in many courses, one course has many students
  • Songs and playlists: one song lives in many playlists, one playlist contains many songs
Every many-to-many relationship has a junction table in the middle, keeping track of who's connected to what.

🔧 Hands-On: Building and querying the likes feature

Populate the likes table and query it the way a real app would:
1
2
3
4
INSERT INTO likes (user_id, post_id)
VALUES (1, 1), (2, 1), (3, 1),
(1, 2), (3, 2),
(2, 3);
Once you run that and then run Select * FROM likes; you should see the following:
Six likes spread across three posts. How does the app calculate how many likes each post has? With a COUNT and a GROUP BY:
1
2
3
4
5
SELECT posts.caption, COUNT(likes.id) AS like_count
FROM posts
LEFT JOIN likes ON posts.id = likes.post_id
GROUP BY posts.id, posts.caption
ORDER BY like_count DESC;
That query powers every like counter you've glanced at. One query, instant popularity rankings.
Showing who liked a specific post:
1
2
3
4
SELECT users.username
FROM likes
INNER JOIN users ON likes.user_id = users.id
WHERE likes.post_id = 1;
Three usernames come back, everyone who liked Post #1. This query powers that "and 47 others" link you tap when curiosity gets the better of you.
Showing a user all the posts they've liked? Flip the direction:
1
2
3
4
SELECT posts.caption, posts.image_url
FROM likes
INNER JOIN posts ON likes.post_id = posts.id
WHERE likes.user_id = 1;
Same junction table, opposite direction. That's your "Posts You've Liked" page. One table, two foreign keys, and you can traverse the relationship from either end.

Taking it to production (because your laptop is not infrastructure)

You can design tables with proper types and constraints. You can normalize data so nothing's duplicated. You can write JOINs across all three relationship types. You can build real features, profiles, feeds, likes, backed by a real database.
Running on your machine.
That's the catch. Right now, the only person who can use your app is you. Close your laptop and the database goes to sleep. If your hard drive fails, your data goes with it. And if you got a million users overnight? Your laptop would sound like a jet engine for about thirty seconds before surrendering.
Production databases run on servers, machines that are always on, always connected, always backed up. Managing those servers yourself means signing up for a second job you didn't apply for: backups, security patches, scaling, replication, monitoring, disaster recovery. All of it yours. At 3 AM. On a holiday weekend.
Do you want to be a database administrator, or do you want to build your app?

Managed databases: outsource the insomnia

Managed database services handle the infrastructure, backups, scaling, replication, security patches, while you focus on your application.
The major options are Amazon RDS, Google Cloud SQL, and Azure Database. They all support MySQL, PostgreSQL, and other engines. The SQL you write doesn't change, same tables, same JOINs, same everything. The only difference is where your database runs and who wakes up at 3 AM when something breaks. (Spoiler: not you.)
A managed service handles:
  • Automated backups. Your data is backed up continuously. Something goes sideways? Restore to any point in time, down to the second.
  • Scalability. Database getting sluggish under load? Scale up the instance, more CPU, more memory, with a few clicks.
  • Replication. Your database gets copied across multiple data centers automatically. If one goes down, another takes over. Your users don't notice.
  • Security. Encryption at rest and in transit. Network isolation. Access control. You focus on writing safe queries and keeping your credentials out of public GitHub repos.

Set Up a MySQL Database on Amazon RDS

Amazon RDS (Relational Database Service) runs a managed MySQL database in the cloud. "Managed" means AWS handles backups, security patches, hardware failures, and scaling. You write the SQL. They keep the server running. Like hiring a building superintendent for your data.

Step 1: Create the Database

Sign in to the AWS Management Console  and search for "RDS" in the top search bar. Choose Aurora and RDS, then choose Create under Create with full configuration.
The form has a lot of options. Most stay at the default.
  • Engine options: - Engine type: MySQL
  • Database creation method: - Select Full configuration
  • Templates: - Select Free tier, which locks the instance to a small size covered by the AWS Free Tier
  • Availability and durability, Deployment options: Leave the default (Single-AZ instance deployment)
  • Under Settings, leave the Edition and Engine version defaults. Then:
  • DB instance identifier: my-app-db
  • Master username: admin
  • Credentials management: Self managed
  • Master password: pick something strong and save it somewhere. You'll need it in a few minutes.
  • Additional credentials settings: Leave the default (Password authentication)
  • Instance configuration: - Leave the default (db.t3.micro or db.t4g.micro). Free tier picks this for you.
  • Storage: - Leave all defaults (20 GB, General Purpose SSD). Plenty for this project.
  • Connectivity, the section that trips up almost everyone:
    • Most connectivity settings stay as default. One setting, if you get it wrong, will cost you an hour staring at a connection timeout error with zero explanation.
  • Compute resource: Don't connect to an EC2 compute resource
  • Virtual private cloud (VPC): Default VPC
  • DB subnet group: default
  • Public access: Yes ← By default this is No. Leave it on No and your laptop can't reach the database. You'll get a timeout. Change it to Yes.
In production, you'd keep Public access set to No and connect through a private network. For learning, Yes is correct.
  • VPC security group: Create new
  • New VPC security group name: my-app-db-sg
  • Everything else: leave as default
Everything else (Tags, Monitoring, Additional configuration): leave all defaults. Don't expand anything.
Scroll to the bottom and click Create database.
Takes 3-5 minutes. AWS is provisioning a server, installing MySQL, configuring backups, setting up encryption. Good time to refill your coffee.

Step 2: Get Your Connection Endpoint

Back in the RDS console, click your database instance. Under Connectivity & security, find the Connect using section. Choose Endpoints. Leave Endpoint type as the default Instance endpoint. Under Additional configurations, Connectivity & security, you’ll see an endpoint that looks like this:
my-app-db.abc123xyz.us-east-1.rds.amazonaws.com
Copy that endpoint. Your app uses this instead of localhost. The port is 3306.

Step 3: Connect with MySQL Workbench and Create Your Database

Open MySQL Workbench and create a new connection:
  • Click the + icon next to "MySQL Connections"
  • Connection Name: RDS - my-app-db
  • Hostname: paste the endpoint you copied
  • Port: 3306
  • Username: admin
  • Click Test Connection, enter your master password
  • Success message? Click OK, save, and double-click the connection to open it
You're connected to a cloud database. Same Workbench interface as a local one.
Create the database and table by entering the following SQL and choosing Run (the lightning icon):
Complete SQL from this post, in order. Copy, paste, and follow along:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
-- Setup
CREATE DATABASE my_app;
USE my_app;

-- Users table
CREATE TABLE users (
id INT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(100) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Insert users
INSERT INTO users (username, email, password)
VALUES ('mateo_jackson', 'mateo@example.com', 'hashed_password_here'),
('mary_major', 'mary@example.com', 'hashed_password_here'),
('paulo_santos', 'paulo@example.com', 'hashed_password_here');

-- Add age column with CHECK constraint
ALTER TABLE users ADD COLUMN age INT CHECK (age \>= 0 AND age \<= 150);

-- Insert a user with a valid age
INSERT INTO users (username, email, password, age)
VALUES ('valid_user', 'valid@example.com', 'hashed_pw', 25);

-- Addresses table (one-to-one)
CREATE TABLE addresses (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL UNIQUE,
street VARCHAR(255) NOT NULL,
city VARCHAR(100) NOT NULL,
state VARCHAR(50) NOT NULL,
zip_code VARCHAR(20) NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
);

INSERT INTO addresses (user_id, street, city, state, zip_code)
VALUES (1, '123 Main St', 'Austin', 'TX', '78701');

-- Posts table (one-to-many)
CREATE TABLE posts (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL,
caption TEXT,
image_url VARCHAR(500),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);

INSERT INTO posts (user_id, caption, image_url)
VALUES (1, 'My first post!', 'https://storage.example.com/img1.jpg'),
(1, 'Another day another post', 'https://storage.example.com/img2.jpg'),
(1, 'Three for three', 'https://storage.example.com/img3.jpg');

-- Update and Delete examples (the other half of CRUD)
UPDATE users SET username = 'mateo_updated' WHERE id = 1;
DELETE FROM posts WHERE id = 3 AND user_id = 1;
UPDATE users SET username = 'mateo_jackson' WHERE id = 1;

-- Re-insert the deleted post for the rest of the examples
INSERT INTO posts (user_id, caption, image_url)
VALUES (1, 'Three for three', 'https://storage.example.com/img3.jpg');

-- Likes table (many-to-many)
CREATE TABLE likes (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL,
post_id INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (post_id) REFERENCES posts(id),
UNIQUE(user_id, post_id)
);

INSERT INTO likes (user_id, post_id)
VALUES (1, 1), (2, 1), (3, 1),
(1, 2), (3, 2),
(2, 3);
Once you're seeing done executing this query, know that all of this data now lives in RDS, and is always available 24/7. Congratulations! You've deployed your database into the cloud on AWS!

Clean Up Your Resources

Done experimenting? Clean up so you don't burn through your credits.
Option 1: Stop the instance (coming back later)
In the RDS console, select your database instance, click Actions → Stop temporarily. This pauses the instance for up to 7 days. RDS auto-restarts it after 7 days for maintenance, so set a reminder if you're stepping away longer.
Stopped instances don't consume compute hours. The Free Tier includes 20 GB of storage, so a single stopped instance costs nothing.
Option 2: Delete the instance (done for good)
  1. In the RDS console, select your database instance
  2. Click Actions → Delete
  3. Uncheck Create final snapshot
  4. Uncheck Retain automated backups
  5. Type delete me in the confirmation field and click Delete
The instance and all its data get permanently removed.
The security group sticks around after you delete the database. It costs nothing, but if you want a clean account: VPC console → Security groups, find my-app-db-sg, delete it.

What's next

You walked into this post knowing somewhere between "nothing" and "I've heard the word SQL before." Now you've got a working database, a solid mental model of how apps store data, and enough SQL to be useful (and occasionally dangerous).
From here:
  • Learn about indexes. Once your tables grow past a few thousand rows, query performance starts to matter. Indexes turn slow queries into fast ones.
  • Explore ACID guarantees. Atomicity, Consistency, Isolation, Durability, the transaction safety net that keeps your data from ending up in a half-written state. Critical for anything involving money, inventory, or user trust.
  • Try a different database. Everything we covered falls under relational databases: tables, rows, foreign keys, SQL. There's a whole parallel universe of non-relational (NoSQL) databases that throw all of this out the window. Graph databases, document stores, key-value stores, each built for different problems.
Normalize your tables, index your foreign keys, and never put everything in one big table.
Your future self will thank you. And your 3 AM self, asleep instead of debugging a data integrity issue? That person will thank you most of all.
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article