Part · Chapter

Views, CTEs, JSON and other powers

A quick tour of what a database does beyond plain tables: saved queries, multi-step queries, documents inside columns, word search, tables split into pieces and the fast way to load a million rows.

The short version

  • A view is a stored query. It runs fresh every time and keeps no rows of its own.
  • MySQL has no materialized views. You emulate one with a summary table refreshed on a timer.
  • CTEs name your steps. WITH turns nested subqueries into stages you read top to bottom.
  • Recursion walks trees. WITH RECURSIVE follows parent_id links level by level.
  • JSON suits the optional. Keys, money and anything a report filters on belong in real columns.
  • Partitioning is for maintenance. Dropping last year's partition beats deleting fifty million rows.

The whole chapter in one table

Everything this chapter covers, with what each capability is for and the support you can count on in each engine. The four that come up most often get a short section below; for the rest, this row plus the documentation at the end is enough.

CapabilityWhat it is forMySQL 8.4 / 9.7 / 26.xPostgreSQL 17 / 18
ViewsSave a SELECT under a name and grant on it (see Users, roles and security)Yes, with WITH CHECK OPTIONYes, INSTEAD OF triggers for complex ones
Materialized viewsCompute a heavy aggregate once and read it many timesNo in MySQL Server (HeatWave only), so use a summary table plus a scheduled eventYes, REFRESH [CONCURRENTLY]
CTEs and recursive CTEsName the steps of one statement; walk a treeYes since 8.0; depth capped by cte_max_recursion_depthYes, plus SEARCH and CYCLE
Set operationsStack two result sets with the same columns (see Joins)Partial UNION always, INTERSECT and EXCEPT since 8.0.31, no FULL OUTER JOINYes, all three
JSONHold a whole document in one columnYes JSON, validated and binary; index via a generated column or a multi-valued indexYes json and jsonb; GIN index on jsonb
Full-text searchRank rows by the words they containYes FULLTEXT index with MATCH ... AGAINSTYes to_tsvector with a GIN index
Generated columnsCompute a column from its own row, then index it (see Indexes and query performance)Yes VIRTUAL and STORED, both indexableYes STORED since 12, virtual since 18
PartitioningSplit a huge table so you can prune reads and drop old rowsYes on InnoDB, but no foreign keysYes, declarative and with foreign keys
Bulk loadingStream a text file straight into a table (see Backup, restore and replication)Yes LOAD DATA [LOCAL] INFILE, mysqlimport, MySQL ShellYes COPY and psql's \copy
Spatial dataStore points and answer "within 5 km of here"Yes built in: POINT, SPATIAL index, ST_ functionsYes through the PostGIS extension
SequencesHand out the next id (see MySQL vs PostgreSQL)No, AUTO_INCREMENT on one column per tableYes, standalone sequences and AS IDENTITY
Vector similarityFind the nearest embeddings for AI featuresPartial VECTOR type since 9.0; distance functions and indexes are HeatWave onlyYes through pgvector, with HNSW or IVFFlat
ExtensionsAdd types, functions, index methods or workersPartial components and pluginsYes CREATE EXTENSION: PostGIS, pgvector, pg_stat_statements, pg_cron, pg_trgm

Views and materialized views

A view is a SELECT statement stored under a name: query it and the engine runs the saved query, so it behaves like a table you never fill. It hides a join from reporting users, it can leave out columns so you grant SELECT on the view instead of the table (see Users, roles and security), and when a table is renamed or split you rewrite the view once and nothing that reads it notices.

A view built on an aggregate, DISTINCT, GROUP BY, UNION or a window function is read only in both engines. PostgreSQL also has materialized views, which store the result on disk and stay stale until you REFRESH them; MySQL has none, so you emulate one with a summary table that a stored procedure fills on a timer from the Event Scheduler (chapter 12), which is the answer to give a developer who asks for one.

Illustration · Both, then PostgreSQL
CREATE VIEW customer_orders AS
SELECT c.name, o.id, o.order_date, o.status, o.total
FROM customers AS c
JOIN orders AS o ON o.customer_id = c.id;

-- PostgreSQL only: the result kept on disk and refreshed on demand
CREATE MATERIALIZED VIEW sales_by_city AS
SELECT c.city, SUM(o.total) AS revenue
FROM customers AS c JOIN orders AS o ON o.customer_id = c.id
GROUP BY c.city;
REFRESH MATERIALIZED VIEW CONCURRENTLY sales_by_city;   -- needs a unique index

Careful

PostgreSQL's REFRESH MATERIALIZED VIEW without CONCURRENTLY blocks every reader until the rebuild finishes. If a dashboard hangs every night at 02:00, look for a scheduled refresh.

Remember

A view is a stored query: always fresh, with no stored rows. A materialized view is stored data: fast, and stale until refreshed.

CTEs and recursive CTEs

A common table expression, written with WITH, names a query result for the duration of one statement. Nothing is stored, and the gain is readability: nested subqueries become named steps you read top to bottom. A recursive CTE refers to itself, so an anchor query picks the roots and a second query joins the previous round to reach the next level, which is how you walk a category tree, an org chart or a bill of materials. Both engines have both forms, MySQL since 8.0, where a runaway walk stops at cte_max_recursion_depth, while PostgreSQL adds SEARCH and CYCLE clauses to order the walk and catch loops.

Illustration · Both
-- categories(id, name, parent_id): Electronics > Peripherals > Keyboards
WITH RECURSIVE tree AS (
  SELECT id, name, parent_id, 1 AS depth
  FROM categories WHERE parent_id IS NULL                    -- anchor: the roots
  UNION ALL
  SELECT c.id, c.name, c.parent_id, t.depth + 1
  FROM categories AS c JOIN tree AS t ON c.parent_id = t.id   -- one level down
)
SELECT id, name, depth FROM tree ORDER BY depth, id;

JSON inside a relational database

Both engines let a column hold a JSON document and reach inside it with SQL, which suits sparse attributes that differ by product type and payloads you keep exactly as they arrived. -> extracts a value at a path or a key and ->> also strips the quotes; MySQL indexes a document through a generated column or a multi-valued index over an array, while PostgreSQL's jsonb takes one GIN index that answers containment and key-existence tests without naming keys in advance; JSON_TABLE turns an array into rows you can join and group, and PostgreSQL 17 added it under that same standard name.

Keep out of JSON anything a report filters on, joins on or must enforce, because a customer_id inside a document gets no foreign key and a price gets no DECIMAL type. The normalization rules from Designing good tables still apply to everything else.

Illustration · MySQL, then PostgreSQL
-- MySQL: a path, plus the multi-valued index that makes the array search fast
SELECT name, attrs->>'$.color' AS color FROM products
WHERE 'usb' MEMBER OF(attrs->'$.tags');
ALTER TABLE products ADD INDEX idx_tags ((CAST(attrs->'$.tags' AS CHAR(30) ARRAY)));

-- PostgreSQL: a key name, and one GIN index for the whole jsonb column
CREATE INDEX idx_products_attrs ON products USING gin (attrs);
SELECT name, attrs->>'color' AS color FROM products
WHERE attrs @> '{"tags": ["usb"]}';

Ask the AI

Path syntax is the kind of thing to hand over: "MySQL 8.4: products has a JSON column attrs with a tags array. List the products whose tags contain 'usb', plus the multi-valued index that makes it fast."

Partitioning: one big table in pieces

Partitioning splits one logical table into physical pieces by a rule on one column, usually RANGE on a date, sometimes LIST on a country or HASH on an id, while applications keep using the one table name; the engine then reads only the partitions your WHERE clause can match. DBAs want it for maintenance: deleting last year's fifty million rows takes hours and lags every replica (see Backup, restore and replication), while dropping the partition that holds them finishes in a moment.

In MySQL every unique key, including the primary key, must contain the partition column, and a partitioned InnoDB table cannot have foreign keys. PostgreSQL declares a parent with PARTITION BY and each piece as its own table, keeps foreign keys, and can ATTACH and DETACH PARTITION CONCURRENTLY without blocking readers.

Illustration · MySQL, then PostgreSQL
-- MySQL: the partition column has to sit in every unique key
CREATE TABLE orders_archive (
  id INT NOT NULL, order_date DATE NOT NULL, total DECIMAL(10,2) NOT NULL,
  PRIMARY KEY (id, order_date)
)
PARTITION BY RANGE (YEAR(order_date)) (
  PARTITION p2025 VALUES LESS THAN (2026),
  PARTITION p2026 VALUES LESS THAN (2027),
  PARTITION pmax  VALUES LESS THAN MAXVALUE
);
ALTER TABLE orders_archive DROP PARTITION p2025;   -- no DELETE, no undo log

-- PostgreSQL: a parent table, then one real table per piece
CREATE TABLE orders_log (
  id INT NOT NULL, order_date DATE NOT NULL, total NUMERIC(10,2) NOT NULL,
  PRIMARY KEY (id, order_date)
) PARTITION BY RANGE (order_date);
CREATE TABLE orders_2026 PARTITION OF orders_log
  FOR VALUES FROM ('2026-01-01') TO ('2027-01-01');

For you as a DBA

The request arrives as "the audit table is 400 GB and the nightly purge takes six hours", and your answer is one partition per month, a job that creates next month's ahead of time, and a drop of the oldest. Forgetting to pre-create is the classic outage: MySQL rows pile into pmax and PostgreSQL without a DEFAULT partition rejects the insert.

Go deeper

Quick check

1. A developer asks for a materialized view on your MySQL 8.4 server. What do you tell them?

MySQL Server has no materialized views, and ALGORITHM=TEMPTABLE only changes how a normal view is executed. PostgreSQL has them natively, and CREATE MATERIALIZED VIEW exists only in MySQL HeatWave from 9.5, which is no help on an 8.4 server. Reread "Views and materialized views".

2. Which task is the classic job for WITH RECURSIVE?

A recursive CTE starts from anchor rows and repeatedly joins the previous round to itself until nothing new appears, which is exactly how you follow parent_id links. Reread "CTEs and recursive CTEs".

3. Which of these is the sign that a value should NOT live inside a JSON column?

Values you filter on, join on or must enforce belong in real columns with types, indexes and foreign keys. Sparse attributes and raw payloads are what JSON is good at. Reread "JSON inside a relational database".

4. In MySQL, what must be true of a table partitioned by order_date?

Those are the two rules that most often stop a MySQL partitioning plan. InnoDB supports partitioning, and a DEFAULT partition is a PostgreSQL concept (MySQL uses a MAXVALUE partition). Reread "Partitioning: one big table in pieces".

5. LOAD DATA INFILE fails with ERROR 1290 mentioning --secure-file-priv. What is going on?

secure_file_priv restricts server-side file access to one directory; SELECT @@secure_file_priv shows which. LOAD DATA LOCAL INFILE sidesteps it by reading on the client, if both sides allow it.