V0 is now available! We'll release moreeee soooon .
Have you ever written an SQL query and wondered, Wait should this be a subquery? Or maybe a view? Or is a CTE better?
In this guide, we'll break down the differences between the three, show working code examples, and help you understand when to use each one.
Here's the database schema we'll be using for the examples:
CREATE DATABASE company;
 
USE company;
 
CREATE TABLE customers (
  id INT PRIMARY KEY,
 
  name VARCHAR(255),
  email VARCHAR(255),
  phone VARCHAR(255)
);
 
CREATE TABLE orders (
  id INT PRIMARY KEY,
 
  customer_id INT,
  order_date DATE,
  total_amount DECIMAL(10, 2),
  FOREIGN KEY (customer_id) REFERENCES customers(id)
);
 
 A subquery is a query inside another query,
often used to filter or calculate values for the outer one. You can use them in the WHERE, FROM, SELECT, or HAVING clauses.
Let's find all customers who placed an order in January 2025 with a total over 100:
SELECT
  *
FROM customers
WHERE id IN (
  SELECT customer_id
  FROM orders
  WHERE total_amount > 100
  AND order_date >= '2025-01-01'
  AND order_date < '2025-02-01'
  );The inner query fetches the IDs of customers who meet the condition. The outer query then selects customer data for those IDs.
This is easy to write for simple cases, but subqueries quickly become hard to read or maintain when things get more complex.
✅ Good for: One-off logic ❌ Bad for: Reuse or deep nesting
A view is a saved query that behaves like a virtual table. You define it once in your database and it's a database object that you can query like a regular table.
Here's a view that lists customers who spent more than 100 in January 2025:
CREATE VIEW vw_top_customers_last_month AS
SELECT
  customers.id,
  customers.name,
  customers.email,
  customers.phone,
  SUM(orders.total_amount) AS total_amount
FROM
  customers
JOIN orders ON customers.id = orders.customer_id
WHERE
  orders.order_date >= '2025-01-01'
  AND orders.order_date < '2025-02-01'
  AND orders.total_amount > 100
GROUP BY customers.id, customers.name, customers.email, customers.phone;Once created, you can query it like this:
SELECT
  *
FROM
  vw_top_customers_last_month;This makes your queries cleaner and easier to reuse, especially when your logic is shared across reports or dashboards.
✅ Good for: Reuse, abstraction, security (limiting columns) ❌ Bad for: Logic used only once or that changes often
A materialized view is like a regular view, but its results are cached (stored on disk), so queries run much faster.
Unlike regular views that re-run their query on each access, materialized views store the results and need manual or scheduled refreshing.
Here's how you create a materialized view for the same top customers example:
CREATE MATERIALIZED VIEW mv_top_customers_last_month AS
SELECT
  customers.id,
  customers.name,
  customers.email,
  customers.phone,
  SUM(orders.total_amount) AS total_amount
FROM
  customers
JOIN orders ON customers.id = orders.customer_id
WHERE
  orders.order_date >= '2025-01-01'
  AND orders.order_date < '2025-02-01'
  AND orders.total_amount > 100
GROUP BY customers.id, customers.name, customers.email, customers.phone;To refresh it when data changes, run:
REFRESH MATERIALIZED VIEW mv_top_customers_last_month;Materialized views are great when query speed is critical but the underlying data doesn't change very often, like in analytics and reporting.
✅ Good for: Performance optimization, heavy queries that don't need real-time freshness ❌ Bad for: Data that changes frequently or needs to be always current
A CTE is like a temporary named result set used only in the query it's defined in. You define it with the WITH keyword.
Here's the same logic we used in the view, but as a CTE:
WITH top_customers_last_month AS (
  SELECT
    customers.id,
    customers.name,
    customers.email,
    customers.phone,
    SUM(orders.total_amount) AS total_amount
  FROM customers
  JOIN orders ON customers.id = orders.customer_id
  WHERE orders.order_date >= DATE_SUB(CURDATE(), INTERVAL 1 MONTH)
    AND orders.total_amount > 100
  GROUP BY customers.id, customers.name, customers.email, customers.phone
)
SELECT * FROM top_customers_last_month;This is perfect for breaking a complex query into logical chunks that are easier to read and debug. Unlike views, CTEs exist only during the execution of the query.
Some queries require recursion, for example, generating a sequence of dates or traversing a tree structure.
Here's a CTE that generates dates from Jan 1 to Jan 7, 2025:
WITH RECURSIVE date_sequence AS (
  SELECT DATE('2025-01-01') AS dt
  UNION ALL
  SELECT dt + INTERVAL 1 DAY
  FROM date_sequence
  WHERE dt < '2025-01-07'
)
SELECT * FROM date_sequence;Recursive CTEs are super handy when you don't have a calendar table or need to walk through parent-child hierarchies.
✅ Good for: Multi-step queries, breaking complexity, recursion ❌ Bad for: Reuse across different queries or modules
| Method | Read Speed | Write Impact | Memory Usage | Best For | 
|---|---|---|---|---|
| Subquery | Medium | None | Low | Simple filters | 
| CTE | Fast | None | Medium | Complex queries | 
| View | Medium | None | Low | Reusable logic | 
| Materialized View | Very Fast | Slower writes | High | Analytics | 
🛑 Avoid subqueries when:
🛑 Avoid views when:
🛑 Avoid CTEs for cross-query reuse, use views instead.
Bonus: Use a CTE when you're building logic that might later be promoted to a view, but you're not ready yet. It keeps things flexible 😉.
Let's see how you might use all three approaches for the same business problem:
Problem: Find customers who spent more than $500 in the last 3 months, with their total spend and order count.
SELECT
  c.name,
  c.email,
  (SELECT COUNT(*) FROM orders WHERE customer_id = c.id) as order_count,
  (SELECT SUM(total_amount) FROM orders WHERE customer_id = c.id) as total_spent
FROM customers c
WHERE c.id IN (
  SELECT customer_id
  FROM orders
  WHERE order_date >= DATE_SUB(CURDATE(), INTERVAL 3 MONTH)
  GROUP BY customer_id
  HAVING SUM(total_amount) > 500
);CREATE VIEW vw_customer_analytics AS
SELECT
  c.id,
  c.name,
  c.email,
  COUNT(o.id) as order_count,
  SUM(o.total_amount) as total_spent,
  MAX(o.order_date) as last_order_date
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
GROUP BY c.id, c.name, c.email;
 
-- Then query it:
SELECT * FROM vw_customer_analytics
WHERE total_spent > 500
AND last_order_date >= DATE_SUB(CURDATE(), INTERVAL 3 MONTH);WITH customer_totals AS (
  SELECT
    customer_id,
    COUNT(*) as order_count,
    SUM(total_amount) as total_spent
  FROM orders
  WHERE order_date >= DATE_SUB(CURDATE(), INTERVAL 3 MONTH)
  GROUP BY customer_id
  HAVING SUM(total_amount) > 500
),
customer_details AS (
  SELECT
    c.id,
    c.name,
    c.email,
    ct.order_count,
    ct.total_spent
  FROM customers c
  JOIN customer_totals ct ON c.id = ct.customer_id
)
SELECT * FROM customer_details
ORDER BYThere's no one-size-fits-all answer, it depends on what you're trying to achieve:
Use a subquery for one-off, simple logic inside another query. Use a view to abstract and reuse logic across multiple queries. Use a CTE to break down complex queries into smaller, more manageable parts.
Let me know what you'd like covered next!
MySQL vs PostgreSQL: The Real-World Engineering Showdown 🥊 Alright, let’s cut through the hype.