diff --git a/lessons/07-performance-and-indexing/05-partitioning/lesson.mdx b/lessons/07-performance-and-indexing/05-partitioning/lesson.mdx
new file mode 100644
index 0000000..3b36b52
--- /dev/null
+++ b/lessons/07-performance-and-indexing/05-partitioning/lesson.mdx
@@ -0,0 +1,147 @@
+Some tables only ever grow: sensor readings, log lines, orders. Left as one giant heap, deleting last year's rows means a slow `DELETE` that leaves bloat behind, and every query scans the whole thing. *Partitioning* splits one logical table into physical children — each holding a slice of the rows — so old data can be dropped instantly and queries touch only the slices they need.
+
+The seed already built one: a `measurements` table partitioned by month, with three monthly children holding 4,368 sensor readings between them.
+
+
+SELECT count(*) FROM measurements;
+
+
+## The parent routes rows to children
+
+`measurements` was created with a partitioning strategy, and each child claims a range of `recorded_at`:
+
+```sql
+CREATE TABLE measurements (
+ id bigint GENERATED ALWAYS AS IDENTITY,
+ sensor_id int NOT NULL,
+ recorded_at timestamptz NOT NULL,
+ reading numeric(6,2) NOT NULL,
+ PRIMARY KEY (id, recorded_at)
+) PARTITION BY RANGE (recorded_at);
+
+CREATE TABLE measurements_2024_01 PARTITION OF measurements
+ FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
+```
+
+You insert into the *parent*, and Postgres routes each row to the child whose range contains its `recorded_at`. The three children carry the rows; the parent holds none of its own. Ask each child directly:
+
+
+SELECT 'jan' AS part, count(\*) FROM measurements_2024_01
+UNION ALL SELECT 'feb', count(\*) FROM measurements_2024_02
+UNION ALL SELECT 'mar', count(\*) FROM measurements_2024_03;
+
+
+1,488 + 1,392 + 1,488 = 4,368 — every parent row physically lives in exactly one child.
+
+One catch of RANGE partitioning: a row whose `recorded_at` falls outside every child's range has nowhere to go and the `INSERT` errors. Try an April row (there is no April partition yet), and watch it fail:
+
+
+INSERT INTO measurements (sensor_id, recorded_at, reading)
+VALUES (1, '2024-04-15 09:00:00', 21.5);
+
+
+*no partition of relation "measurements" found for row* — we'll fix that below.
+
+## Partition pruning: scan only what matters
+
+The payoff is on reads. Filter on the partition key and Postgres skips children that can't match — *partition pruning*. `EXPLAIN` shows exactly which children the planner kept:
+
+
+EXPLAIN
+SELECT count(*) FROM measurements
+WHERE recorded_at >= '2024-02-01' AND recorded_at \< '2024-03-01';
+
+
+Only `measurements_2024_02` appears in the plan — January and March were pruned away before a single row was read. Now drop the filter and the planner has to keep them all:
+
+
+EXPLAIN
+SELECT count(*) FROM measurements;
+
+
+All three children show up. That difference is the whole point: on a table with three years of monthly partitions, a one-month query reads 1 partition instead of 36. Pruning works whenever the filter references the partition key.
+
+## Indexes propagate from the parent
+
+Create an index on the *parent* and Postgres creates a matching one on every current child — and on any child you add later. One statement, all partitions covered:
+
+
+CREATE INDEX ON measurements (sensor_id);
+
+
+
+SELECT indexrelid::regclass AS index_name, indrelid::regclass AS on_table
+FROM pg_index
+WHERE indrelid IN ('measurements_2024_01'::regclass, 'measurements_2024_02'::regclass, 'measurements_2024_03'::regclass)
+ORDER BY on_table;
+
+
+Each child got its own `sensor_id` index automatically. Combined with pruning, a query like "sensor 3 in February" narrows to one partition and then uses that partition's index.
+
+## Archiving is instant
+
+Here is the operational win. To retire January, you don't run a `DELETE` over millions of rows — you drop or detach the whole child in one metadata operation:
+
+```sql
+DROP TABLE measurements_2024_01; -- gone, no bloat, no VACUUM
+ALTER TABLE measurements DETACH PARTITION measurements_2024_01; -- keep it, unlink it
+```
+
+`DROP TABLE` reclaims the space immediately; `DETACH` turns the child into an ordinary standalone table you can archive or move elsewhere. Both beat a bulk `DELETE`, which would leave dead tuples for `VACUUM` to clean up.
+
+## Other partitioning strategies
+
+RANGE isn't the only option. When rows fall into discrete categories rather than ordered ranges, use LIST:
+
+```sql
+CREATE TABLE events (region text, payload jsonb) PARTITION BY LIST (region);
+CREATE TABLE events_eu PARTITION OF events FOR VALUES IN ('de', 'fr', 'es');
+CREATE TABLE events_us PARTITION OF events FOR VALUES IN ('us', 'ca');
+```
+
+And to spread rows evenly with no natural key — for parallelism rather than pruning by value — HASH partitioning assigns each row to one of N buckets by a hash of the key: `PARTITION BY HASH (id)`, then children declared `FOR VALUES WITH (MODULUS 4, REMAINDER 0)`, and so on.
+
+For RANGE you can also add a catch-all so out-of-range rows land somewhere instead of erroring:
+
+```sql
+CREATE TABLE measurements_default PARTITION OF measurements DEFAULT;
+```
+
+Handy as a safety net, though rows in the DEFAULT partition can't be pruned by value — treat it as a place to notice stragglers, not a substitute for real partitions.
+
+## Your turn
+
+April data is arriving. Add a fourth monthly partition for April 2024, then insert the reading that failed earlier — this time it has a home to route into.
+
+
+CREATE TABLE measurements_2024_04 PARTITION OF measurements
+ FOR VALUES FROM ('2024-04-01') TO ('2024-05-01');
+
+
+
+INSERT INTO measurements (sensor_id, recorded_at, reading)
+VALUES (1, '2024-04-15 09:00:00', 21.5);
+
+
+Confirm the row landed in the new child, not anywhere else:
+
+
+SELECT count(*) FROM measurements_2024_04;
+
+
+One row — routed straight into April by its `recorded_at`. The parent's total is now 4,369, and the new child inherited the `sensor_id` index from the parent without you asking.
+
+
+Create `measurements_2024_04` and insert the April row above. We'll confirm the child partition holds exactly one row.
+
+
+## What you learned
+
+- `PARTITION BY RANGE (col)` splits one logical table into physical children, each owning a slice of the key; inserts into the parent route to the matching child automatically.
+- Rows outside every range error out — add more partitions, or a `DEFAULT` partition as a catch-all (which can't be pruned by value).
+- Partition pruning lets a query with a partition-key filter skip non-matching children entirely — `EXPLAIN` shows only the partitions actually scanned.
+- An index created on the parent propagates to every child, current and future.
+- Archiving old data is a metadata operation: `DROP TABLE partition` reclaims space instantly, `DETACH PARTITION` unlinks a child to keep — both avoid a bulk `DELETE` and its `VACUUM` cleanup.
+- LIST partitions by discrete categories, HASH spreads rows evenly across buckets.
+
+Up next: Module 8 — Programmability, starting with views.
diff --git a/lessons/07-performance-and-indexing/05-partitioning/lesson.yaml b/lessons/07-performance-and-indexing/05-partitioning/lesson.yaml
new file mode 100644
index 0000000..8e6945b
--- /dev/null
+++ b/lessons/07-performance-and-indexing/05-partitioning/lesson.yaml
@@ -0,0 +1,19 @@
+title: Partitioning
+summary: Split one huge logical table into physical children by range so bulk deletes are cheap and queries prune to just the partitions they need.
+estimatedMinutes: 15
+tags:
+ - partitioning
+ - partition-by-range
+ - partition-pruning
+ - declarative-partitioning
+ - explain
+authors:
+ - exekias
+seed: seed.sql
+checks:
+ - id: april-partition-populated
+ type: row-count
+ description: Add a measurements_2024_04 partition and insert one April row that routes into it.
+ table: measurements_2024_04
+ expect:
+ rowCount: 1
diff --git a/lessons/07-performance-and-indexing/05-partitioning/seed.sql b/lessons/07-performance-and-indexing/05-partitioning/seed.sql
new file mode 100644
index 0000000..925b9eb
--- /dev/null
+++ b/lessons/07-performance-and-indexing/05-partitioning/seed.sql
@@ -0,0 +1,30 @@
+-- Seed for "05-partitioning": a time-series style table of sensor readings.
+-- measurements is a RANGE-partitioned parent split by month, with three monthly
+-- child partitions pre-created. We populate a few thousand rows spread evenly
+-- across the three months so partition pruning and per-partition counts are real.
+
+CREATE TABLE measurements (
+ id bigint GENERATED ALWAYS AS IDENTITY,
+ sensor_id int NOT NULL,
+ recorded_at timestamptz NOT NULL,
+ reading numeric(6,2) NOT NULL,
+ PRIMARY KEY (id, recorded_at)
+) PARTITION BY RANGE (recorded_at);
+
+CREATE TABLE measurements_2024_01 PARTITION OF measurements
+ FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
+
+CREATE TABLE measurements_2024_02 PARTITION OF measurements
+ FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');
+
+CREATE TABLE measurements_2024_03 PARTITION OF measurements
+ FOR VALUES FROM ('2024-03-01') TO ('2024-04-01');
+
+-- 4368 rows: one reading every 30 minutes across the three months, cycling
+-- 5 sensors. Jan gets 1488, Feb (leap) 1392, Mar 1488 — exactly filling the
+-- three partitions with nothing left over.
+INSERT INTO measurements (sensor_id, recorded_at, reading)
+SELECT (g % 5) + 1,
+ timestamptz '2024-01-01 00:00:00' + (g * interval '30 minutes'),
+ round((20 + (g % 100) * 0.1)::numeric, 2)
+FROM generate_series(0, 4367) AS g;
diff --git a/lessons/07-performance-and-indexing/module.yaml b/lessons/07-performance-and-indexing/module.yaml
new file mode 100644
index 0000000..c66b32b
--- /dev/null
+++ b/lessons/07-performance-and-indexing/module.yaml
@@ -0,0 +1,3 @@
+title: Performance and indexing
+difficulty: advanced
+summary: Make queries fast — indexes, reading EXPLAIN, and choosing the right index type.