RoadsideCoderNotes
postgressqlsupabasedatabases

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.

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.

Note

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 postgres
docker exec -it learn-postgres psql -U postgres

Everything through the Indexes section works identically either way. Only the Supabase-specific features (Auth/RLS, Realtime, Storage, Edge Functions) require an actual Supabase project.

Note

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
);
Output
CREATE TABLE
insert into users (display_name) values ('Alice'), ('Bob');
Output
INSERT 0 2
select * from users where display_name like 'A%' order by id desc;
Output
iddisplay_name
1Alice
(1 row)

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);
Output (one line per statement, in order)
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
Watch out

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()
);
Output
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.)

usersiduuidPKdisplay_nametextemailtextageintcreated_attimestamptzpostsiduuidPKuser_iduuidFKtitletextstatustextmetadatajsonbcreated_attimestamptzcommentsiduuidPKpost_iduuidFKuser_iduuidFKbodytextcreated_attimestamptz

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);
Output
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.

Note

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';
Output (one line per statement, in order)
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}';
Output
titlereading_timetags
My First Post2["intro", "hello-world"]
(1 row)

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

usersposts

Only the overlap survives

users
Alice29
Bob34
Carol22
posts
u1My First Post
u1Draft Thoughts
u2Bob's Post
kept (matched) dropped (no match)
select u.display_name, p.title
from users u
inner join posts p on p.user_id = u.id;
Output
display_nametitle
AliceMy First Post
AliceDraft Thoughts
BobBob's Post
(3 rows)

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

usersposts

Everything on the left, plus overlap

users
Alice29
Bob34
Carol22
posts
u1My First Post
u1Draft Thoughts
u2Bob's Post
kept (matched) kept, NULL on the right dropped
select u.display_name, p.title
from users u
left join posts p on p.user_id = u.id;
Output
display_nametitle
AliceMy First Post
AliceDraft Thoughts
BobBob's Post
CarolNULL
(4 rows)

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

postsusers

Everything on the right, plus overlap

users
Alice29
Bob34
Carol22
posts
u1My First Post
u1Draft Thoughts
u2Bob's Post
kept (matched) kept, NULL on the left dropped
select u.display_name, p.title
from posts p
right join users u on p.user_id = u.id;
Output (identical to the LEFT JOIN above)
display_nametitle
AliceMy First Post
AliceDraft Thoughts
BobBob's Post
CarolNULL
(4 rows)

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

usersposts

Everything from both sides

users
Alice29
Bob34
Carol22
posts
u1My First Post
u1Draft Thoughts
u2Bob's Post
kept (matched) kept, NULL on the other side dropped
select u.display_name, p.title
from users u
full outer join posts p on p.user_id = u.id;
Output (same as LEFT JOIN here — every post has a valid user_id)
display_nametitle
AliceMy First Post
AliceDraft Thoughts
BobBob's Post
CarolNULL
(4 rows)

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

usersstatuses

Every row paired with every row

users
Alice29
Bob34
Carol22
posts
u1My First Post
u1Draft Thoughts
u2Bob's Post
kept (every combination) dropped
select u.display_name, s.status
from users u
cross join (values ('draft'), ('published'), ('archived')) as s(status);
Output
display_namestatus
Alicedraft
Alicepublished
Alicearchived
Bobdraft
Bobpublished
Bobarchived
Caroldraft
Carolpublished
Carolarchived
(9 rows)

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

users

One table, matched against itself

users
Alice29
Bob34
Carol22
posts
u1My First Post
u1Draft Thoughts
u2Bob's Post
kept (matched) dropped (no other user shares this age)
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;
Output
display_namedisplay_nameage
(0 rows)

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.

Note

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;
Output
display_namepost_count
Alice2
Bob1
Carol0
(3 rows)

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;
Output
display_namepost_count
Alice2
(1 row)

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);
Output (avg age = (29 + 34 + 22) / 3 = 28.33)
display_nameage
Alice29
Bob34
(2 rows)
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;
Output
display_namepublished_count
Alice1
Bob1
(2 rows)

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
);
Output
     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;
Output
titlenum_comments
My First Post2
Bob's Post0
(2 rows)

"Draft Thoughts" is excluded, since it's not in the published_posts CTE (its status = 'draft').

Watch out

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;
Output
BEGIN
UPDATE 1
INSERT 0 1
COMMIT
begin;
delete from users where email = 'alice@example.com';
rollback;
Output
BEGIN
DELETE 1
ROLLBACK
select display_name, email from users order by display_name;
Output
display_nameemail
Alicealice@example.com
Bobbob@example.com
Carolcarol@example.com
(3 rows)

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');
Output (exact cost/time numbers vary by machine — the shape is what matters)
 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 ms

The 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');
Output (on this tiny table, the planner will likely still pick Seq Scan)
 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 ms

On 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;
Output, before the composite index
 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 ms
Output, after the composite index
 Seq 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.

Note

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 playlist

What 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);
Output
ALTER TABLE
CREATE POLICY
CREATE POLICY
CREATE POLICY
Honesty checkpoint

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;
Output
ALTER PUBLICATION
Honesty checkpoint

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]
);
Output
CREATE POLICY
Honesty checkpoint

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' },
  })
})
Output (triggering with a comment on “My First Post”)
{"notified":"My First Post"}
Watch out

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