MySQL Views
A view is a saved SQL query that behaves like a virtual table. You query it exactly like a real table, but the data is computed on the fly from the underlying base tables. Views are one of the most powerful tools for simplifying complex queries, enforcing security, and creating stable interfaces between application layers.
Creating a View
The basic syntax uses CREATE VIEW:
-- Basic syntax CREATE VIEW view_name AS SELECT column1, column2 FROM table_name WHERE condition; -- Real example: active customers CREATE VIEW active_customers AS SELECT customer_id, first_name, last_name, email, created_at FROM customers WHERE status = 'active' AND deleted_at IS NULL;
Once created, query the view exactly like a table:
SELECT * FROM active_customers; SELECT email FROM active_customers WHERE last_name = 'Smith';
CREATE VIEW does not store data — it stores the query definition. Every time you SELECT from a view, MySQL executes the underlying query against the current data.OR REPLACE
-- Replace existing view definition without DROP + CREATE CREATE OR REPLACE VIEW active_customers AS SELECT customer_id, first_name, last_name, email, phone, created_at FROM customers WHERE status = 'active' AND deleted_at IS NULL; -- Monthly revenue view CREATE OR REPLACE VIEW monthly_revenue AS SELECT DATE_FORMAT(order_date, '%Y-%m') AS month, SUM(total_amount) AS revenue, COUNT(*) AS order_count FROM orders GROUP BY DATE_FORMAT(order_date, '%Y-%m');
Views with JOINs
Views are especially useful for hiding complex multi-table joins:
CREATE OR REPLACE VIEW order_details_view AS SELECT o.order_id, o.order_date, o.status, c.first_name, c.last_name, c.email, p.product_name, oi.quantity, oi.unit_price, (oi.quantity * oi.unit_price) AS line_total FROM orders o JOIN customers c ON o.customer_id = c.customer_id JOIN order_items oi ON o.order_id = oi.order_id JOIN products p ON oi.product_id = p.product_id; -- Application code stays clean SELECT * FROM order_details_view WHERE order_id = 1042;
Updatable vs Read-Only Views
MySQL allows INSERT, UPDATE, and DELETE through a view — but only if the view is updatable. A view is updatable when:
It references exactly one base table
It does not use DISTINCT
It does not use aggregate functions (SUM, COUNT, AVG, etc.)
It does not use GROUP BY or HAVING
It does not use UNION or UNION ALL
It does not use subqueries in the SELECT list
-- This view IS updatable (single table, no aggregates)
CREATE VIEW simple_customers AS
SELECT customer_id, first_name, last_name, email, status
FROM customers;
-- UPDATE through the view modifies the base table
UPDATE simple_customers
SET status = 'inactive'
WHERE customer_id = 7;
-- INSERT through the view inserts into the base table
INSERT INTO simple_customers (first_name, last_name, email, status)
VALUES ('Jane', 'Doe', 'jane@example.com', 'active');
-- This view is NOT updatable (uses GROUP BY + aggregate)
CREATE VIEW revenue_by_month AS
SELECT DATE_FORMAT(order_date, '%Y-%m') AS month, SUM(total) AS total
FROM orders
GROUP BY month;WITH CHECK OPTION
WITH CHECK OPTION prevents INSERT/UPDATE through a view from creating rows that would fall outside the view's WHERE clause:
CREATE VIEW active_customers AS
SELECT customer_id, first_name, last_name, email, status
FROM customers
WHERE status = 'active'
WITH CHECK OPTION;
-- This INSERT succeeds — satisfies WHERE status = 'active'
INSERT INTO active_customers (first_name, last_name, email, status)
VALUES ('Bob', 'Jones', 'bob@example.com', 'active');
-- This UPDATE fails — row would become invisible through the view
UPDATE active_customers
SET status = 'inactive'
WHERE customer_id = 3;
-- ERROR 1369: CHECK OPTION failed 'mydb.active_customers'
-- LOCAL vs CASCADED for nested views
CREATE VIEW high_value_orders AS
SELECT * FROM orders WHERE total_amount > 100;
-- LOCAL: only checks this view's WHERE clause
CREATE VIEW recent_high_value AS
SELECT * FROM high_value_orders
WHERE order_date >= '2024-01-01'
WITH LOCAL CHECK OPTION;
-- CASCADED (default): checks all ancestor view WHERE clauses too
CREATE VIEW recent_high_value_strict AS
SELECT * FROM high_value_orders
WHERE order_date >= '2024-01-01'
WITH CASCADED CHECK OPTION;Inspecting View Definitions
-- Show the CREATE VIEW statement SHOW CREATE VIEW active_customersG -- List all views in current database SHOW FULL TABLES WHERE Table_type = 'VIEW'; -- Query information_schema for view metadata SELECT TABLE_NAME AS view_name, IS_UPDATABLE, SECURITY_TYPE, CHARACTER_SET_CLIENT FROM information_schema.VIEWS WHERE TABLE_SCHEMA = DATABASE();
ALTER and DROP Views
-- ALTER VIEW replaces the definition ALTER VIEW active_customers AS SELECT customer_id, first_name, last_name, email, phone, status, created_at FROM customers WHERE status = 'active' AND deleted_at IS NULL; -- Drop a single view DROP VIEW active_customers; -- Drop if it exists (avoids error when view may not exist) DROP VIEW IF EXISTS active_customers; -- Drop multiple views at once DROP VIEW IF EXISTS active_customers, order_details_view, monthly_revenue;
View Performance: MERGE vs TEMPTABLE
MySQL does NOT cache view results by default. It uses one of two execution algorithms:
Algorithm | Behaviour | When Used |
|---|---|---|
MERGE | View SQL is merged into outer query — one combined query executes | Simple views: no GROUP BY, DISTINCT, or aggregates |
TEMPTABLE | View result materializes into a temp table first, then the outer query runs on it | Views with GROUP BY, DISTINCT, aggregates, or UNION |
-- Force MERGE algorithm (MySQL may override if not possible) CREATE ALGORITHM = MERGE VIEW fast_view AS SELECT customer_id, email FROM customers WHERE status = 'active'; -- Check the algorithm MySQL chose EXPLAIN SELECT * FROM active_customers WHERE last_name = 'Smith'G
Views for Security — Column-Level Access
Views let you expose only specific columns to a database user, hiding sensitive data like salaries, passwords, or PII:
-- employees table has salary, ssn, password_hash columns -- Expose only safe columns to the reporting role CREATE VIEW employees_public AS SELECT employee_id, first_name, last_name, department, job_title, hire_date FROM employees; -- Grant SELECT on the VIEW only — never on the base table GRANT SELECT ON mydb.employees_public TO 'reporting_user'@'%'; -- The reporting user sees employee info but never salary or SSN
SQL SECURITY: DEFINER vs INVOKER
-- DEFINER (default): view runs with the creator's privileges CREATE DEFINER = 'admin'@'localhost' SQL SECURITY DEFINER VIEW admin_view AS SELECT * FROM sensitive_table; -- INVOKER: view runs with the calling user's privileges CREATE SQL SECURITY INVOKER VIEW invoker_view AS SELECT * FROM sensitive_table; -- If the calling user lacks SELECT on sensitive_table, query errors
Replacing Complex Subqueries with Views
-- Before: nested subquery that appears in many application queries SELECT customer_id, total_spent FROM ( SELECT o.customer_id, SUM(oi.quantity * oi.unit_price) AS total_spent FROM orders o JOIN order_items oi ON o.order_id = oi.order_id GROUP BY o.customer_id ) AS spending WHERE total_spent > 1000; -- Step 1: Extract into a named view CREATE VIEW customer_lifetime_value AS SELECT o.customer_id, SUM(oi.quantity * oi.unit_price) AS total_spent, COUNT(DISTINCT o.order_id) AS total_orders, MIN(o.order_date) AS first_order, MAX(o.order_date) AS last_order FROM orders o JOIN order_items oi ON o.order_id = oi.order_id GROUP BY o.customer_id; -- Step 2: All queries are now clean and consistent SELECT c.first_name, c.last_name, clv.total_spent, clv.total_orders FROM customers c JOIN customer_lifetime_value clv ON c.customer_id = clv.customer_id WHERE clv.total_spent > 1000 ORDER BY clv.total_spent DESC;
Practical Example: Sales Dashboard View
CREATE OR REPLACE VIEW sales_dashboard AS
SELECT
DATE_FORMAT(o.order_date, '%Y-%m') AS month,
COUNT(DISTINCT o.order_id) AS total_orders,
COUNT(DISTINCT o.customer_id) AS unique_customers,
SUM(oi.quantity * oi.unit_price) AS gross_revenue,
AVG(oi.quantity * oi.unit_price) AS avg_order_value,
SUM(CASE WHEN o.status = 'returned'
THEN oi.quantity * oi.unit_price
ELSE 0 END) AS returns_amount
FROM orders o
JOIN order_items oi ON o.order_id = oi.order_id
GROUP BY DATE_FORMAT(o.order_date, '%Y-%m')
ORDER BY month DESC;
-- Dashboard query is now a one-liner
SELECT * FROM sales_dashboard LIMIT 12;View Limitations
Views cannot have their own indexes — only base table indexes are used
Views cannot use temporary tables as their base
TEMPTABLE views re-execute the full aggregation on every query
Circular view references are not allowed
ORDER BY inside a view is ignored when the outer query provides its own ORDER BY
Views do not support stored procedure calls inside them
Best Practices
Use a naming convention like _view or _v suffix to distinguish views from real tables
Use views to create stable API contracts — app code queries the view while the schema can evolve underneath
Prefer MERGE-compatible views (no GROUP BY or aggregates) for index-efficient queries
Add WITH CHECK OPTION on writeable views to prevent silent data drift
Audit views periodically — orphaned views referencing dropped tables produce cryptic runtime errors
For very heavy aggregation views, consider a summary table refreshed by a scheduled event instead