Learn Postgres in One Video (Full Notes)
Complete written notes for the Postgres tutorial: data types, constraints, every join type, aggregations, subqueries, CTEs, transactions, indexes, EXPLAIN ANALYZE, and what Supabase actually adds on top.
- 01Environment setup
- 02SQL basics recap
- 03Data types & constraints
- Evolving the table with ALTER TABLE
- jsonb: Postgres's binary JSON type
- Proving the CHECK constraint works
- 04Seed data
- Querying the jsonb column
- 05Relationships & every join type
- INNER JOIN
- LEFT (OUTER) JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- CROSS JOIN
- Self join
- 06Aggregations & subqueries
- GROUP BY / HAVING
- Subqueries
- 07CTEs (Common Table Expressions)
- 08Transactions
- 09Indexes & EXPLAIN ANALYZE
- 10What Supabase adds on top
- Auth + Row Level Security
- Realtime
- Storage
- Edge Functions
- 11Recap: Postgres vs. Supabase, honestly
Full written notes to follow along with the video, or come back to as a reference later. Every query below is runnable: copy it straight into Supabase's SQL Editor, psql, or pgAdmin.
All output shown assumes you run every statement top to bottom, in order, on a fresh database. If your results differ, check whether a prior statement was skipped or run twice before assuming something's wrong.
Environment setup
Two ways to follow along:
Option A, Supabase (hosted). No local install. Create a free project at Supabase, open the SQL Editor, and you're using standard Postgres SQL. Nothing Supabase-specific until the back half of this guide.
Option B, local Postgres via Docker.
docker run --name learn-postgres \
-e POSTGRES_PASSWORD=postgres \
-p 5432:5432 \
-d postgresdocker exec -it learn-postgres psql -U postgresEverything through the Indexes section works identically either way. Only the Supabase-specific features (Auth/RLS, Realtime, Storage, Edge Functions) require an actual Supabase project.
Want a visual table browser locally instead of the psql terminal? Run pgAdmin as a second Docker container on the same network. The full walkthrough is in the environment setup appendix below.
SQL basics recap
A fast refresher before we build the real schema. The running example throughout this guide is a tiny blog: users, posts, comments.
create table users (
id serial primary key,
display_name text
);CREATE TABLE
insert into users (display_name) values ('Alice'), ('Bob');INSERT 0 2
select * from users where display_name like 'A%' order by id desc;| id | display_name |
|---|---|
| 1 | Alice |
This users table is deliberately bare: no constraints, and id isn't even the right type yet. The next section rebuilds it for real, evolving it in place rather than dropping it, since that's what actually happens to schemas once they have live data.
Data types & constraints
Constraints are where bad data gets stopped at the database level, not just in application code. We'll also switch to uuid primary keys instead of serial integers, which work better for public-facing IDs and multi-service setups.
Evolving the table with ALTER TABLE
create extension if not exists pgcrypto;
-- Add new columns first — nullable for now, since the existing row (Alice) has no values yet
alter table users add column new_id uuid default gen_random_uuid();
alter table users add column email text;
alter table users add column age int;
alter table users add column created_at timestamptz not null default now();
-- Backfill a placeholder email so it can be safely made NOT NULL next
update users set email = lower(display_name) || '@example.com' where email is null;
-- Swap the primary key from the old integer id to the new uuid column
alter table users drop constraint users_pkey;
alter table users drop column id;
alter table users rename column new_id to id;
alter table users add primary key (id);
-- Now that every row has a value, these constraints can be safely enforced
alter table users alter column display_name set not null;
alter table users alter column email set not null;
alter table users add constraint users_email_key unique (email);
alter table users add constraint users_age_check check (age >= 13);CREATE EXTENSION ALTER TABLE ALTER TABLE ALTER TABLE ALTER TABLE UPDATE 1 ALTER TABLE ALTER TABLE ALTER TABLE ALTER TABLE ALTER TABLE ALTER TABLE ALTER TABLE ALTER TABLE
This is genuinely the messy part of real schema migrations. Swapping a primary key type on a live table means shuffling through a temporary column, backfilling, and re-adding constraints in the right order, not writing a single clean statement. Most tutorials skip this part entirely.
create table posts (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references users(id) on delete cascade,
title text not null,
body text,
status text not null default 'draft'
check (status in ('draft', 'published', 'archived')),
published_at timestamptz,
metadata jsonb not null default '{}',
created_at timestamptz not null default now()
);
create table comments (
id uuid primary key default gen_random_uuid(),
post_id uuid not null references posts(id) on delete cascade,
user_id uuid not null references users(id) on delete cascade,
body text not null,
created_at timestamptz not null default now()
);CREATE TABLE CREATE TABLE
Without on delete cascade, deleting a referenced user would throw a foreign key violation instead of cascading. That's a design decision made at table-creation time, which is why posts and comments are created fresh rather than evolved with ALTER TABLE.
Here's the full schema now that all three tables exist, and how they connect. (Open in Eraser for an interactive version.)
Dashed lines follow the foreign keys: posts.user_id → users.id, comments.post_id → posts.id, comments.user_id → users.id
jsonb: Postgres's binary JSON type
posts.metadata is jsonb, the type that separates Postgres from most relational databases. Use structured columns for data you know the shape of (title, status), and jsonb for data whose shape varies or isn't worth a migration for every new field (tags, SEO metadata, per-post settings).
jsonb vs json: jsonb stores data in parsed binary format. That's slightly slower to insert, much faster to query, and indexable. Use jsonb unless you specifically need to preserve exact key order.
Proving the CHECK constraint works
insert into users (email, display_name, age)
values ('kid@example.com', 'Too Young', 5);ERROR: new row for relation "users" violates check constraint "users_age_check" DETAIL: Failing row contains (..., kid@example.com, Too Young, 5, ...).
Bad data cannot enter the table, regardless of what the application layer does.
status uses a CHECK constraint here. A real Postgres enum type is the stricter alternative, typo-proof but harder to evolve since adding a value later requires ALTER TYPE. CHECK is used here because it's easier to change as requirements evolve: create type post_status as enum ('draft', 'published', 'archived');
Seed data
insert into users (email, display_name, age) values
('alice@example.com', 'Alice', 29),
('bob@example.com', 'Bob', 34),
('carol@example.com', 'Carol', 22);
insert into posts (user_id, title, body, status, published_at, metadata)
select id, 'My First Post', 'Hello world!', 'published', now(),
'{"featured": true, "reading_time_minutes": 2, "tags": ["intro", "hello-world"]}'
from users where email = 'alice@example.com';
insert into posts (user_id, title, body, status, published_at, metadata)
select id, 'Draft Thoughts', 'Not ready yet', 'draft', null, '{}'
from users where email = 'alice@example.com';
insert into posts (user_id, title, body, status, published_at, metadata)
select id, 'Bob''s Post', 'Some content', 'published', now(),
'{"featured": false, "reading_time_minutes": 5, "tags": ["opinion"]}'
from users where email = 'bob@example.com';
-- Carol intentionally has zero posts — needed to demonstrate outer joins
insert into comments (post_id, user_id, body)
select p.id, u.id, 'Great post!'
from posts p, users u
where p.title = 'My First Post' and u.email = 'bob@example.com';
insert into comments (post_id, user_id, body)
select p.id, u.id, 'Thanks for sharing'
from posts p, users u
where p.title = 'My First Post' and u.email = 'carol@example.com';INSERT 0 3 INSERT 0 1 INSERT 0 1 INSERT 0 1 INSERT 0 1 INSERT 0 1
Carol has a comment but no posts. That asymmetry is what makes the join section below meaningful.
Querying the jsonb column
select title, metadata->>'reading_time_minutes' as reading_time, metadata->'tags' as tags
from posts
where metadata @> '{"featured": true}';| title | reading_time | tags |
|---|---|---|
| My First Post | 2 | ["intro", "hello-world"] |
-> returns a JSON value (stays jsonb), ->> returns it as text, and @> checks containment: "does this jsonb contain this jsonb." Only "My First Post" comes back since it's the only one with featured: true.
Relationships & every join type
Joins are about which rows survive the match, not syntax to memorize. Picture two circles, users and posts, and each join type is a different answer to "which parts of the overlap and leftovers do I want?" Carol has no matching row in posts. That's the gap every join below either fills or drops. Each preview below shows the same users/posts rows from Seed Data, with a dot marking whether a row is kept (matched), kept with NULLs, or dropped entirely.
INNER JOIN
Only the overlap survives
| Alice | 29 | |
| Bob | 34 | |
| Carol | 22 |
| u1 | My First Post | |
| u1 | Draft Thoughts | |
| u2 | Bob's Post |
select u.display_name, p.title
from users u
inner join posts p on p.user_id = u.id;| display_name | title |
|---|---|
| Alice | My First Post |
| Alice | Draft Thoughts |
| Bob | Bob's Post |
Carol is absent entirely, since she has no matching post row. Inner join is the most common default, and also the #1 cause of "missing data" bugs when a left join was actually needed.
LEFT (OUTER) JOIN
Everything on the left, plus overlap
| Alice | 29 | |
| Bob | 34 | |
| Carol | 22 |
| u1 | My First Post | |
| u1 | Draft Thoughts | |
| u2 | Bob's Post |
select u.display_name, p.title
from users u
left join posts p on p.user_id = u.id;| display_name | title |
|---|---|
| Alice | My First Post |
| Alice | Draft Thoughts |
| Bob | Bob's Post |
| Carol | NULL |
Carol now appears, with NULL for title. This is the join you want for "show me everyone, plus their related data if any exists."
RIGHT JOIN
Everything on the right, plus overlap
| Alice | 29 | |
| Bob | 34 | |
| Carol | 22 |
| u1 | My First Post | |
| u1 | Draft Thoughts | |
| u2 | Bob's Post |
select u.display_name, p.title
from posts p
right join users u on p.user_id = u.id;| display_name | title |
|---|---|
| Alice | My First Post |
| Alice | Draft Thoughts |
| Bob | Bob's Post |
| Carol | NULL |
Same result as the LEFT JOIN, just with table order flipped. Rarely used in practice; most people just reorder tables and use LEFT JOIN instead.
FULL OUTER JOIN
Everything from both sides
| Alice | 29 | |
| Bob | 34 | |
| Carol | 22 |
| u1 | My First Post | |
| u1 | Draft Thoughts | |
| u2 | Bob's Post |
select u.display_name, p.title
from users u
full outer join posts p on p.user_id = u.id;| display_name | title |
|---|---|
| Alice | My First Post |
| Alice | Draft Thoughts |
| Bob | Bob's Post |
| Carol | NULL |
A FULL OUTER JOIN only differs from LEFT when the right side also has unmatched rows. It's useful for reconciliation queries where you want unmatched rows from both sides at once.
CROSS JOIN
Every row paired with every row
| Alice | 29 | |
| Bob | 34 | |
| Carol | 22 |
| u1 | My First Post | |
| u1 | Draft Thoughts | |
| u2 | Bob's Post |
select u.display_name, s.status
from users u
cross join (values ('draft'), ('published'), ('archived')) as s(status);| display_name | status |
|---|---|
| Alice | draft |
| Alice | published |
| Alice | archived |
| Bob | draft |
| Bob | published |
| Bob | archived |
| Carol | draft |
| Carol | published |
| Carol | archived |
Every combination of every row, no ON clause. 3 users × 3 statuses = 9 rows. Useful for generating report matrices; rarely intended by accident.
Self join
One table, matched against itself
| Alice | 29 | |
| Bob | 34 | |
| Carol | 22 |
| u1 | My First Post | |
| u1 | Draft Thoughts | |
| u2 | Bob's Post |
select a.display_name, b.display_name, a.age
from users a
join users b on a.age = b.age and a.id < b.id;| display_name | display_name | age |
|---|
Alice (29), Bob (34), and Carol (22) all have different ages, so no pairs match with this seed data. The a.id < b.id condition avoids self-matches and duplicate mirrored pairs. To see a real match, insert a user with age = 29 and re-run.
Recap: INNER = intersection only. LEFT = everything on the left. RIGHT = everything on the right (flip tables + use LEFT instead). FULL = everything from both sides. CROSS = all combinations.
Aggregations & subqueries
GROUP BY / HAVING
select u.display_name, count(p.id) as post_count
from users u
left join posts p on p.user_id = u.id
group by u.display_name
order by post_count desc;| display_name | post_count |
|---|---|
| Alice | 2 |
| Bob | 1 |
| Carol | 0 |
count(p.id) counts non-null values only, so Carol correctly gets 0, not 1.
select u.display_name, count(p.id) as post_count
from users u
join posts p on p.user_id = u.id
group by u.display_name
having count(p.id) > 1;| display_name | post_count |
|---|---|
| Alice | 2 |
WHERE filters rows before grouping; HAVING filters groups after aggregation. You can't write WHERE count(*) > 1, since COUNT doesn't exist yet at the WHERE stage.
Subqueries
select display_name, age
from users
where age > (select avg(age) from users);| display_name | age |
|---|---|
| Alice | 29 |
| Bob | 34 |
select * from (
select u.display_name, count(p.id) as published_count
from users u
join posts p on p.user_id = u.id and p.status = 'published'
group by u.display_name
) as published_counts
where published_count >= 1;| display_name | published_count |
|---|---|
| Alice | 1 |
| Bob | 1 |
A subquery in FROM (a derived table). Alice's "Draft Thoughts" doesn't count, since it's status = 'draft', not 'published'.
select title from posts p
where exists (
select 1 from comments c where c.post_id = p.id
);title ---------------- My First Post (1 row)
EXISTS stops scanning at the first match, which is often faster than IN on large tables.
CTEs (Common Table Expressions)
CTEs are named subqueries using WITH. They make complex queries readable, and in Postgres they can also recurse.
with published_posts as (
select * from posts where status = 'published'
),
comment_counts as (
select post_id, count(*) as num_comments
from comments
group by post_id
)
select pp.title, coalesce(cc.num_comments, 0) as num_comments
from published_posts pp
left join comment_counts cc on cc.post_id = pp.id
order by num_comments desc;| title | num_comments |
|---|---|
| My First Post | 2 |
| Bob's Post | 0 |
"Draft Thoughts" is excluded, since it's not in the published_posts CTE (its status = 'draft').
A recursive CTE (WITH RECURSIVE) is the standard pattern for tree-shaped data: comment threads, org charts, category hierarchies. It needs a parent_id column that isn't in this schema, so it's conceptual here rather than runnable:
with recursive comment_thread as (
select id, post_id, body, parent_id, 1 as depth
from comments
where parent_id is null
union all
select c.id, c.post_id, c.body, c.parent_id, ct.depth + 1
from comments c
join comment_thread ct on c.parent_id = ct.id
)
select * from comment_thread order by depth;Transactions
Multiple writes that must succeed or fail together need a transaction.
begin;
update posts
set user_id = (select id from users where email = 'bob@example.com')
where title = 'My First Post';
insert into comments (post_id, user_id, body)
select id, user_id, 'Post reassigned to a new author'
from posts where title = 'My First Post';
commit;BEGIN UPDATE 1 INSERT 0 1 COMMIT
begin;
delete from users where email = 'alice@example.com';
rollback;BEGIN DELETE 1 ROLLBACK
select display_name, email from users order by display_name;| display_name | |
|---|---|
| Alice | alice@example.com |
| Bob | bob@example.com |
| Carol | carol@example.com |
Alice is still here. The DELETE inside the transaction never actually took effect, because it was rolled back. This is atomicity, one of Postgres's ACID guarantees: without transactions, a crash mid-sequence leaves data half-written.
Indexes & EXPLAIN ANALYZE
Don't just take "add an index" on faith. Here's how to prove it helped.
explain analyze
select * from posts
where user_id = (select id from users where email = 'alice@example.com'); Seq Scan on posts (cost=0.05..1.09 rows=1 width=...) (actual time=0.020..0.023 rows=1 loops=1)
Filter: (user_id = $0)
InitPlan 1 (returns $0)
-> Seq Scan on users (cost=0.00..1.04 rows=1 width=16) (actual time=0.008..0.009 rows=1 loops=1)
Filter: (email = 'alice@example.com'::text)
Planning Time: 0.15 ms
Execution Time: 0.04 msThe plan executes bottom-to-top. Seq Scan means Postgres read every row to find matches, which is fine on a tiny table but costly at scale. Two numbers matter most: cost (the planner's own estimate) and actual time (real milliseconds, what users feel).
create index idx_posts_user_id on posts(user_id);
explain analyze
select * from posts
where user_id = (select id from users where email = 'alice@example.com'); Seq Scan on posts (cost=0.05..1.09 rows=1 width=...) (actual time=0.018..0.021 rows=1 loops=1)
Filter: (user_id = $0)
InitPlan 1 (returns $0)
-> Seq Scan on users (cost=0.00..1.04 rows=1 width=16) (actual time=0.007..0.008 rows=1 loops=1)
Filter: (email = 'alice@example.com'::text)
Planning Time: 0.14 ms
Execution Time: 0.03 msOn a tiny table, the planner may still choose a sequential scan. That's correct behavior, not a bug. Indexes prove their value at scale and with selective filters, not on 6 rows. Seed thousands of rows instead and this flips to Index Scan using idx_posts_user_id.
explain analyze
select * from posts where status = 'published' order by published_at desc;
create index idx_posts_status_published_at on posts(status, published_at desc);
explain analyze
select * from posts where status = 'published' order by published_at desc; Sort (cost=1.10..1.11 rows=2 width=...) (actual time=0.025..0.026 rows=2 loops=1)
Sort Key: published_at DESC
-> Seq Scan on posts (cost=0.00..1.09 rows=2 width=...) (actual time=0.010..0.012 rows=2 loops=1)
Filter: (status = 'published'::text)
Planning Time: 0.10 ms
Execution Time: 0.04 msSeq Scan on posts (cost=0.00..1.09 rows=2 width=...) (actual time=0.009..0.011 rows=2 loops=1) Filter: (status = 'published'::text) Planning Time: 0.12 ms Execution Time: 0.03 ms
The explicit Sort node disappears, since the index already returns rows pre-sorted. At real scale this also flips the scan itself to an Index Scan.
The reasoning framework, not a rule to memorize: index columns used in WHERE, ON, or ORDER BY when the table is large and selective enough that scanning is genuinely expensive. Every index adds write overhead on INSERT/UPDATE. It's a deliberate tradeoff, verified with EXPLAIN ANALYZE, not a default action.
Ready to build something real with this?
See how these exact Postgres concepts power a full-stack Next.js app with Supabase: auth, real-time data, file storage, and more.
Watch the Next.js + Supabase playlistWhat Supabase adds on top
Everything above (data types, constraints, joins, aggregations, subqueries, CTEs, transactions, indexes) is stock Postgres. It works identically self-hosted, on RDS, or anywhere else. Supabase is a hosted Postgres database with extra services layered on top; here's exactly where that line sits.
Auth + Row Level Security
alter table posts enable row level security;
create policy "Published posts are public"
on posts for select
using (status = 'published');
create policy "Users can insert their own posts"
on posts for insert
with check (auth.uid() = user_id);
create policy "Users can modify their own posts"
on posts for update
using (auth.uid() = user_id);ALTER TABLE CREATE POLICY CREATE POLICY CREATE POLICY
enable row level security and create policy are vanilla Postgres syntax and work on any Postgres install. Supabase's contribution is auth.uid(), the hosted auth/JWT service behind it, and the policy-management UI.
Realtime
alter publication supabase_realtime add table comments;ALTER PUBLICATION
alter publication is standard Postgres logical-replication syntax. The managed websocket delivery layer, and the pre-wired supabase_realtime publication, are Supabase's addition on top of a Postgres feature that's existed for years.
Storage
create policy "Users can upload their own avatars"
on storage.objects for insert
with check (
bucket_id = 'avatars'
and auth.uid()::text = (storage.foldername(name))[1]
);CREATE POLICY
storage.objects is a real Postgres table Supabase maintains. This is the exact same RLS pattern as the posts policy above, applied to file permissions. That consistency (everything is Postgres rows and policies, even file uploads) is the core architectural idea behind Supabase.
Edge Functions
// supabase/functions/notify-new-comment/index.ts
import { createClient } from 'jsr:@supabase/supabase-js@2'
Deno.serve(async (req) => {
const { record } = await req.json()
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
const { data: post } = await supabase
.from('posts')
.select('title')
.eq('id', record.post_id)
.single()
return new Response(JSON.stringify({ notified: post?.title }), {
headers: { 'Content-Type': 'application/json' },
})
}){"notified":"My First Post"}Unlike Auth/RLS, Realtime, and Storage, this one genuinely isn't "Postgres under the hood." Edge Functions are Supabase's own compute product (serverless TypeScript on Deno), included here because it's useful, not because it's secretly a Postgres feature.
Recap: Postgres vs. Supabase, honestly
Stock Postgres, works anywhere, no vendor involved:
- Data types, CHECK constraints, foreign keys,
ON DELETE CASCADE - All join types: inner, left, right, full outer, cross, self joins
GROUP BY/HAVING, scalar and correlated subqueries,EXISTS- CTEs, including recursive CTEs
- Transactions:
BEGIN/COMMIT/ROLLBACK, ACID guarantees - Indexes and
EXPLAIN ANALYZE, since the query planner is core Postgres - Row Level Security itself, which predates Supabase entirely
What Supabase actually added:
- A hosted auth service issuing JWTs, plus the
auth.uid()helper that plugs into RLS policies you already know how to write - A managed realtime layer on top of Postgres logical replication
- An S3-compatible storage service whose permission model reuses the same RLS pattern
- Edge Functions, genuinely separate compute, not a repackaged Postgres feature
- A UI (Table Editor, SQL Editor, Policy editor) on top of all of it, and free hosting to get started
You didn't learn "Supabase" here. You learned Postgres, and Supabase happened to be a good place to see how far that knowledge stretches before you need custom infrastructure. Everything above the Edge Functions line is portable to any Postgres host.
Want to build a full-stack app on this same database?
See how this exact Postgres + Supabase setup powers a real Next.js project: auth, RLS, file uploads, and more, end to end.
Watch the Next.js + Supabase playlist