Common Table Expression (CTE) di SQL: Tutorial Lengkap
TL;DR
CTE (Common Table Expression) itu kayak temporary table yang cuma exist dalam satu query. Bikin query kompleks jadi lebih readable dan maintainable. Ada juga recursive CTE buat handle data hierarki kayak struktur organisasi.
Apa Itu CTE (Common Table Expression)?
Pernah bikin query SQL yang panjang banget sampe kamu sendiri bingung bacanya? Nested subquery di mana-mana, alias yang ga jelas, dan pas mau debug rasanya mau nangis?
Nah, CTE itu solusinya.
CTE atau Common Table Expression adalah cara buat bikin "temporary result set" yang bisa kamu pake dalam satu query. Mirip bikin variabel dulu sebelum dipake. Query jadi lebih structured dan gampang dibaca.
Kenapa sih CTE penting banget?
- Query jadi lebih readable
- Gampang di-debug (bisa test per bagian)
- Bisa dipake berkali-kali dalam satu query
- Support recursive buat data hierarki
Apa yang Akan Kamu Pelajari
- [ ] Sintaks dasar CTE dengan WITH clause
- [ ] Keuntungan CTE dibanding subquery
- [ ] Multiple CTEs dalam satu query
- [ ] Recursive CTE untuk data hierarki
- [ ] 5 contoh penggunaan CTE yang praktis
- [ ] Performance considerations
Sintaks Dasar CTE
Ini struktur dasar CTE:
WITH nama_cte AS (
SELECT ...
FROM ...
WHERE ...
)
SELECT *
FROM nama_cte;
Simple kan? Kamu define dulu CTE-nya pake WITH, kasih nama, terus isi dengan query. Setelah itu, kamu bisa pake nama CTE itu kayak tabel biasa.
Dataset yang Akan Kita Pakai
Buat tutorial ini, kita pakai dataset perusahaan Indonesia. Ada tabel karyawan dengan struktur hierarki.
Tabel: karyawan
| karyawan_id | nama | jabatan | departemen | manager_id | gaji | tanggal_masuk |
|---|---|---|---|---|---|---|
| 1 | Budi Santoso | CEO | Direksi | NULL | 150000000 | 2015-01-15 |
| 2 | Siti Rahayu | CFO | Finance | 1 | 100000000 | 2016-03-01 |
| 3 | Andi Wijaya | CTO | IT | 1 | 100000000 | 2016-06-15 |
| 4 | Dewi Lestari | Finance Manager | Finance | 2 | 50000000 | 2018-02-01 |
| 5 | Rudi Hartono | IT Manager | IT | 3 | 50000000 | 2018-05-10 |
| 6 | Maya Putri | Senior Accountant | Finance | 4 | 25000000 | 2019-08-20 |
| 7 | Agus Pratama | Senior Developer | IT | 5 | 30000000 | 2019-11-01 |
| 8 | Rina Wati | Junior Accountant | Finance | 4 | 15000000 | 2021-01-15 |
| 9 | Doni Saputra | Junior Developer | IT | 5 | 18000000 | 2021-03-20 |
| 10 | Linda Sari | Staff Accountant | Finance | 6 | 12000000 | 2022-07-01 |
| 11 | Hendra Kusuma | Staff Developer | IT | 7 | 15000000 | 2022-09-15 |
| 12 | Putri Indah | Intern | IT | 7 | 5000000 | 2024-01-10 |
Tabel: orders
| order_id | karyawan_id | customer_name | order_date | amount |
|---|---|---|---|---|
| 101 | 4 | PT Maju Jaya | 2024-01-15 | 50000000 |
| 102 | 6 | CV Berkah | 2024-01-20 | 25000000 |
| 103 | 4 | PT Sentosa | 2024-02-05 | 75000000 |
| 104 | 8 | UD Makmur | 2024-02-10 | 15000000 |
| 105 | 6 | PT Maju Jaya | 2024-03-01 | 60000000 |
Contoh 1: CTE Basic untuk Query yang Lebih Clean
Misalnya kamu mau cari karyawan yang gajinya di atas rata-rata departemennya. Tanpa CTE, query-nya bakal berantakan.
Tanpa CTE (Pake Subquery):
SELECT
k.nama,
k.departemen,
k.gaji,
(SELECT AVG(gaji) FROM karyawan WHERE departemen = k.departemen) AS avg_dept_gaji
FROM karyawan k
WHERE k.gaji > (
SELECT AVG(gaji)
FROM karyawan
WHERE departemen = k.departemen
);
Subquery-nya diulang dua kali. Ga efisien dan susah dibaca.
Dengan CTE:
WITH avg_gaji_per_dept AS (
SELECT
departemen,
AVG(gaji) AS avg_gaji
FROM karyawan
GROUP BY departemen
)
SELECT
k.nama,
k.departemen,
k.gaji,
a.avg_gaji AS avg_dept_gaji
FROM karyawan k
JOIN avg_gaji_per_dept a ON k.departemen = a.departemen
WHERE k.gaji > a.avg_gaji;
Hasil:
| nama | departemen | gaji | avg_dept_gaji |
|---|---|---|---|
| Siti Rahayu | Finance | 100000000 | 40400000 |
| Andi Wijaya | IT | 100000000 | 36333333.33 |
| Dewi Lestari | Finance | 50000000 | 40400000 |
| Rudi Hartono | IT | 50000000 | 36333333.33 |
Lebih clean kan? CTE-nya dihitung sekali, terus dipake di JOIN dan WHERE.
Contoh 2: Multiple CTEs
Kamu bisa define lebih dari satu CTE dalam satu query. Tinggal pisahin pake koma.
Misalnya, kamu mau cari departemen yang total gajinya di atas rata-rata dan punya lebih dari 3 karyawan.
WITH gaji_per_dept AS (
SELECT
departemen,
SUM(gaji) AS total_gaji,
COUNT(*) AS jumlah_karyawan
FROM karyawan
GROUP BY departemen
),
avg_total_gaji AS (
SELECT AVG(total_gaji) AS rata_rata
FROM gaji_per_dept
)
SELECT
g.departemen,
g.total_gaji,
g.jumlah_karyawan,
a.rata_rata AS avg_total_semua_dept
FROM gaji_per_dept g
CROSS JOIN avg_total_gaji a
WHERE g.total_gaji > a.rata_rata
AND g.jumlah_karyawan > 3;
Hasil:
| departemen | total_gaji | jumlah_karyawan | avg_total_semua_dept |
|---|---|---|---|
| Finance | 202000000 | 5 | 190000000 |
| IT | 218000000 | 6 | 190000000 |
Dua CTE: pertama hitung per departemen, kedua hitung rata-rata. Terus digabungin di query utama.
Contoh 3: CTE untuk Step-by-Step Analysis
CTE juga berguna banget buat analisis yang bertahap. Setiap step jadi satu CTE.
Misalnya, kamu mau analisis performa sales per karyawan Finance:
WITH finance_employees AS (
-- Step 1: Filter karyawan Finance
SELECT karyawan_id, nama, jabatan
FROM karyawan
WHERE departemen = 'Finance'
),
sales_summary AS (
-- Step 2: Hitung total sales per karyawan
SELECT
karyawan_id,
COUNT(*) AS total_orders,
SUM(amount) AS total_revenue
FROM orders
GROUP BY karyawan_id
),
final_report AS (
-- Step 3: Gabungin data karyawan dengan sales
SELECT
fe.nama,
fe.jabatan,
COALESCE(ss.total_orders, 0) AS total_orders,
COALESCE(ss.total_revenue, 0) AS total_revenue
FROM finance_employees fe
LEFT JOIN sales_summary ss ON fe.karyawan_id = ss.karyawan_id
)
SELECT
*,
CASE
WHEN total_revenue >= 100000000 THEN 'Top Performer'
WHEN total_revenue >= 50000000 THEN 'Good'
WHEN total_revenue > 0 THEN 'Needs Improvement'
ELSE 'No Sales'
END AS performance_category
FROM final_report
ORDER BY total_revenue DESC;
Hasil:
| nama | jabatan | total_orders | total_revenue | performance_category |
|---|---|---|---|---|
| Dewi Lestari | Finance Manager | 2 | 125000000 | Top Performer |
| Maya Putri | Senior Accountant | 2 | 85000000 | Good |
| Rina Wati | Junior Accountant | 1 | 15000000 | Needs Improvement |
| Siti Rahayu | CFO | 0 | 0 | No Sales |
| Linda Sari | Staff Accountant | 0 | 0 | No Sales |
Setiap CTE punya tugas spesifik. Gampang banget buat debug kalau ada error.
Contoh 4: CTE vs Subquery (Perbandingan)
Kapan pake CTE, kapan pake subquery? Ini aturan simpelnya:
Pake CTE kalau:
- Query kompleks dengan multiple steps
- Subquery yang sama dipake berkali-kali
- Mau query yang readable dan maintainable
- Butuh recursive query
Pake Subquery kalau:
- Query simple, satu level aja
- Subquery cuma dipake sekali
- Performance jadi concern (kadang subquery lebih cepat)
Contoh yang sama, dua versi:
-- Versi Subquery (untuk case simple, ini OK)
SELECT nama, gaji
FROM karyawan
WHERE gaji > (SELECT AVG(gaji) FROM karyawan);
-- Versi CTE (overkill untuk case ini)
WITH avg_gaji AS (
SELECT AVG(gaji) AS rata_rata FROM karyawan
)
SELECT k.nama, k.gaji
FROM karyawan k, avg_gaji a
WHERE k.gaji > a.rata_rata;
Untuk case simple kayak gini, subquery lebih ringkas. Tapi kalau query-nya kompleks, CTE jauh lebih bagus.
Contoh 5: Recursive CTE untuk Data Hierarki
Nah ini yang keren. Recursive CTE bisa handle data hierarki kayak struktur organisasi.
Recursive CTE punya dua bagian:
1. Anchor member: Query awal (base case)
2. Recursive member: Query yang refer ke dirinya sendiri
Misalnya, kamu mau nampilin semua bawahan dari seorang manager, termasuk bawahan dari bawahannya (sampai level terbawah).
WITH RECURSIVE org_hierarchy AS (
-- Anchor: Mulai dari CEO
SELECT
karyawan_id,
nama,
jabatan,
manager_id,
1 AS level,
nama AS path
FROM karyawan
WHERE manager_id IS NULL
UNION ALL
-- Recursive: Cari bawahan
SELECT
k.karyawan_id,
k.nama,
k.jabatan,
k.manager_id,
oh.level + 1,
oh.path || ' > ' || k.nama AS path
FROM karyawan k
INNER JOIN org_hierarchy oh ON k.manager_id = oh.karyawan_id
)
SELECT
level,
REPEAT(' ', level - 1) || nama AS nama_indented,
jabatan,
path
FROM org_hierarchy
ORDER BY path;
Hasil:
| level | nama_indented | jabatan | path |
|---|---|---|---|
| 1 | Budi Santoso | CEO | Budi Santoso |
| 2 | Andi Wijaya | CTO | Budi Santoso > Andi Wijaya |
| 3 | Rudi Hartono | IT Manager | Budi Santoso > Andi Wijaya > Rudi Hartono |
| 4 | Agus Pratama | Senior Developer | Budi Santoso > Andi Wijaya > Rudi Hartono > Agus Pratama |
| 5 | Hendra Kusuma | Staff Developer | Budi Santoso > Andi Wijaya > Rudi Hartono > Agus Pratama > Hendra Kusuma |
| 5 | Putri Indah | Intern | Budi Santoso > Andi Wijaya > Rudi Hartono > Agus Pratama > Putri Indah |
| 4 | Doni Saputra | Junior Developer | Budi Santoso > Andi Wijaya > Rudi Hartono > Doni Saputra |
| 2 | Siti Rahayu | CFO | Budi Santoso > Siti Rahayu |
| 3 | Dewi Lestari | Finance Manager | Budi Santoso > Siti Rahayu > Dewi Lestari |
| 4 | Maya Putri | Senior Accountant | Budi Santoso > Siti Rahayu > Dewi Lestari > Maya Putri |
| 5 | Linda Sari | Staff Accountant | Budi Santoso > Siti Rahayu > Dewi Lestari > Maya Putri > Linda Sari |
| 4 | Rina Wati | Junior Accountant | Budi Santoso > Siti Rahayu > Dewi Lestari > Rina Wati |
Keren kan? Sekarang kamu bisa liat struktur organisasi lengkap dari satu query.
Contoh 6: Recursive CTE untuk Hitung Total Bawahan
Mau tau berapa total bawahan setiap manager, termasuk bawahan nggak langsung?
WITH RECURSIVE subordinates AS (
-- Anchor: Setiap karyawan
SELECT
karyawan_id,
nama,
manager_id,
karyawan_id AS root_manager
FROM karyawan
UNION ALL
-- Recursive: Cari bawahan dari bawahan
SELECT
k.karyawan_id,
k.nama,
k.manager_id,
s.root_manager
FROM karyawan k
INNER JOIN subordinates s ON k.manager_id = s.karyawan_id
WHERE k.karyawan_id != s.root_manager -- Hindari self-reference
)
SELECT
m.nama AS manager,
m.jabatan,
COUNT(*) - 1 AS total_bawahan -- Minus 1 karena include diri sendiri
FROM subordinates s
JOIN karyawan m ON s.root_manager = m.karyawan_id
GROUP BY m.karyawan_id, m.nama, m.jabatan
HAVING COUNT(*) > 1
ORDER BY total_bawahan DESC;
Hasil:
| manager | jabatan | total_bawahan |
|---|---|---|
| Budi Santoso | CEO | 11 |
| Andi Wijaya | CTO | 5 |
| Siti Rahayu | CFO | 4 |
| Rudi Hartono | IT Manager | 4 |
| Dewi Lestari | Finance Manager | 3 |
| Agus Pratama | Senior Developer | 2 |
| Maya Putri | Senior Accountant | 1 |
Budi (CEO) punya 11 bawahan total. Andi (CTO) punya 5 bawahan di cabang IT-nya.
Contoh 7: CTE untuk Running Total
CTE juga bagus buat kalkulasi running/cumulative.
WITH monthly_orders AS (
SELECT
DATE_TRUNC('month', order_date) AS bulan,
SUM(amount) AS monthly_revenue
FROM orders
GROUP BY DATE_TRUNC('month', order_date)
)
SELECT
bulan,
monthly_revenue,
SUM(monthly_revenue) OVER (ORDER BY bulan) AS cumulative_revenue
FROM monthly_orders
ORDER BY bulan;
Hasil:
| bulan | monthly_revenue | cumulative_revenue |
|---|---|---|
| 2024-01-01 | 75000000 | 75000000 |
| 2024-02-01 | 90000000 | 165000000 |
| 2024-03-01 | 60000000 | 225000000 |
CTE bikin query lebih clean. Agregasi bulanan di CTE, running total di query utama.
Common Mistakes yang Harus Dihindari
Mistake 1: Lupa RECURSIVE Keyword
-- SALAH (recursive tanpa keyword RECURSIVE)
WITH org_hierarchy AS (
SELECT * FROM karyawan WHERE manager_id IS NULL
UNION ALL
SELECT k.* FROM karyawan k
JOIN org_hierarchy oh ON k.manager_id = oh.karyawan_id -- Error!
)
SELECT * FROM org_hierarchy;
-- BENAR
WITH RECURSIVE org_hierarchy AS (
-- ...
)
SELECT * FROM org_hierarchy;
Kalau CTE refer ke dirinya sendiri, HARUS pake keyword RECURSIVE.
Mistake 2: Infinite Loop di Recursive CTE
-- POTENSI INFINITE LOOP
WITH RECURSIVE bad_cte AS (
SELECT 1 AS n
UNION ALL
SELECT n + 1 FROM bad_cte -- Ga ada termination condition!
)
SELECT * FROM bad_cte;
Selalu pastikan ada kondisi yang bikin recursion berhenti. Bisa pake WHERE atau LIMIT.
-- BENAR (pake termination)
WITH RECURSIVE good_cte AS (
SELECT 1 AS n
UNION ALL
SELECT n + 1 FROM good_cte WHERE n < 10 -- Stop di 10
)
SELECT * FROM good_cte;
Mistake 3: CTE Ga Dipake
-- SIA-SIA (define CTE tapi ga dipake)
WITH unused_cte AS (
SELECT * FROM karyawan
)
SELECT * FROM orders; -- CTE-nya kemana?
Kalau define CTE, pastikan dipake di query utama.
Mistake 4: Nama CTE Sama dengan Nama Tabel
-- CONFUSING
WITH karyawan AS ( -- Sama kayak nama tabel asli
SELECT * FROM karyawan WHERE departemen = 'IT'
)
SELECT * FROM karyawan; -- Yang mana nih?
Kasih nama CTE yang deskriptif dan beda dari nama tabel.
-- LEBIH JELAS
WITH it_employees AS (
SELECT * FROM karyawan WHERE departemen = 'IT'
)
SELECT * FROM it_employees;
Performance Considerations
CTE Materialized vs Non-Materialized
Di PostgreSQL dan beberapa database lain, CTE bisa jadi "materialized" (hasil disimpan sementara) atau "non-materialized" (di-inline kayak subquery).
-- PostgreSQL 12+: Paksa non-materialized
WITH my_cte AS NOT MATERIALIZED (
SELECT * FROM karyawan
)
SELECT * FROM my_cte WHERE departemen = 'IT';
Non-materialized kadang lebih cepat karena optimizer bisa push down filter.
Kapan CTE Lebih Lambat
- CTE yang cuma dipake sekali kadang lebih lambat dari subquery
- Recursive CTE dengan depth dalam bisa slow
- CTE yang return data besar terus di-filter lagi
Tips Performance
- Profile query kamu dengan EXPLAIN ANALYZE
- Kalau CTE cuma dipake sekali dan simple, pertimbangkan subquery
- Untuk recursive CTE, limit kedalaman kalau bisa
- Index kolom yang dipake di JOIN dalam recursive CTE
Tips dan Best Practices
1. Nama CTE yang Deskriptif
-- Kurang bagus
WITH a AS (...), b AS (...)
-- Lebih bagus
WITH active_employees AS (...), sales_summary AS (...)
Nama yang jelas bikin query self-documenting.
2. Satu CTE, Satu Tugas
Jangan bikin CTE yang ngerjain terlalu banyak hal. Break down jadi beberapa CTE.
3. Comment Complex CTEs
WITH
-- Ambil karyawan yang aktif (masuk sebelum 2023)
active_employees AS (
SELECT * FROM karyawan WHERE tanggal_masuk < '2023-01-01'
),
-- Hitung performa dari active employees
performance_summary AS (
...
)
4. Test CTE Secara Terpisah
Sebelum jalanin query lengkap, test setiap CTE dulu:
-- Test CTE pertama dulu
WITH my_cte AS (
SELECT ...
)
SELECT * FROM my_cte LIMIT 10;
Latihan
Coba kerjain query ini:
Soal: Bikin query yang nampilin setiap karyawan beserta nama manager-nya dan nama "grand manager" (manager dari manager). Pake CTE biar readable.
Klik untuk lihat hint
1. Bikin CTE yang JOIN karyawan dengan dirinya sendiri untuk dapet manager 2. JOIN lagi untuk dapet grand manager 3. Handle NULL untuk yang ga punya managerKlik untuk lihat solusi
WITH employee_manager AS (
SELECT
e.karyawan_id,
e.nama AS employee_name,
e.jabatan,
m.nama AS manager_name,
m.manager_id AS grand_manager_id
FROM karyawan e
LEFT JOIN karyawan m ON e.manager_id = m.karyawan_id
),
full_hierarchy AS (
SELECT
em.karyawan_id,
em.employee_name,
em.jabatan,
COALESCE(em.manager_name, 'No Manager') AS manager_name,
COALESCE(gm.nama, 'No Grand Manager') AS grand_manager_name
FROM employee_manager em
LEFT JOIN karyawan gm ON em.grand_manager_id = gm.karyawan_id
)
SELECT *
FROM full_hierarchy
ORDER BY karyawan_id;
**Hasil:**
| karyawan_id | employee_name | jabatan | manager_name | grand_manager_name |
|-------------|---------------|---------|--------------|-------------------|
| 1 | Budi Santoso | CEO | No Manager | No Grand Manager |
| 2 | Siti Rahayu | CFO | Budi Santoso | No Grand Manager |
| 3 | Andi Wijaya | CTO | Budi Santoso | No Grand Manager |
| 4 | Dewi Lestari | Finance Manager | Siti Rahayu | Budi Santoso |
| 5 | Rudi Hartono | IT Manager | Andi Wijaya | Budi Santoso |
| ... | ... | ... | ... | ... |
FAQ
CTE sama subquery, bagusan mana?
Tergantung case-nya. Kalau query simple dan cuma dipake sekali, subquery lebih ringkas. Kalau query kompleks, multiple steps, atau hasil yang sama dipake berkali-kali, CTE menang jauh soal readability. Contoh 4 di atas nunjukin dua versi query yang sama. Buat case simple, CTE malah overkill.
Apakah CTE bikin query lebih cepat?
Nggak selalu. CTE itu soal readability, bukan speed. Di PostgreSQL 12+ optimizer bisa inline CTE kayak subquery, jadi performanya mirip. Tapi CTE yang cuma dipake sekali kadang malah lebih lambat dari subquery. Cek pake EXPLAIN ANALYZE dulu sebelum kamu simpulin mana yang lebih cepat.
Semua database support CTE nggak?
Hampir semua database modern support: PostgreSQL, MySQL 8.0+, SQL Server, Oracle, SQLite, sampai BigQuery. Yang perlu kamu cek itu MySQL versi lama. Di bawah 8.0 belum ada CTE. Sintaks recursive juga beda dikit antar database, misalnya SQL Server nggak butuh keyword RECURSIVE.
Bisa nggak CTE dipake di UPDATE atau DELETE?
Bisa kok. Di PostgreSQL dan SQL Server, kamu bisa pake WITH di depan UPDATE, DELETE, atau INSERT. Pola umumnya: filter data yang mau diubah lewat CTE, terus reference CTE-nya di statement utama. Ini berguna banget buat cleanup data yang kondisinya kompleks.
Kesimpulan
CTE ngebantu banget buat query SQL yang kompleks. Inget poin-poin ini:
- CTE bikin query lebih readable dan maintainable
- Pake WITH clause untuk define CTE
- Bisa bikin multiple CTEs dalam satu query
- Recursive CTE buat data hierarki (jangan lupa keyword RECURSIVE)
- Nama deskriptif bikin query self-documenting
- Test dan profile untuk performance
CTE itu kayak refactoring di programming. Query tetep jalan sama, tapi jauh lebih gampang dibaca dan di-maintain.
Selanjutnya
Kalau kamu udah paham CTE, next step-nya:
- Window Functions - analisis yang lebih advanced
- Cohort Analysis - retention analysis pake CTE
- Subquery - kapan pake subquery vs CTE
Mau praktek langsung? Mulai latihan SQL gratis
Latihan interaktif, langsung di browser.
Artikel Terkait
7 SQL Client Gratis Terbaik buat Analis Data (2026)
Nggak perlu bayar buat nulis query. Ini 7 SQL client gratis yang beneran dipakai analis, plus kelebihan dan kekurangannya masing-masing.
SQL Style Guide: Cara Nulis Query yang Enak Dibaca Tim
Sepuluh aturan SQL style guide yang bikin query kamu kebaca sama tim, dan sama kamu sendiri 6 bulan lagi. Lengkap dengan contoh sebelum-sesudah.
Take-Home Test SQL Data Analyst: Contoh Soal + Cara Ngerjainnya
Contoh soal take-home test SQL yang beneran dipakai perusahaan, dikerjain step by step, dari baca soal, nulis query, sampai nyusun insight yang bikin recruiter nengok.