Python · SQL · Web Dev · Java · AI/ML tracks launching soon — your one platform for all of IT
Intermediate

Views — Virtual Tables

Store a query as a named database object — create, replace, and drop views, build layered reporting abstractions, control access with views, and understand when materialized views beat regular ones

12–16 min April 2026
Section 10 · Advanced SQL Objects
Advanced SQL Objects · 7 modulesModule 45

// Part 01

What a View Is

A view is a named query stored in the database schema. Once created, it can be queried exactly like a table — you write SELECT * FROM view_name and the database executes the underlying query transparently. No data is physically stored in the view itself (for regular views). The view is simply a saved query definition that the database substitutes at query time.

Views solve three problems simultaneously: they eliminate repeated complex queries by giving them a stable name, they create a security boundary by exposing only the columns and rows a user should see, and they build an abstraction layer that shields downstream consumers from schema changes in base tables.

View lifecycle — CREATE, USE, DROP
-- Create a view
CREATE VIEW view_name AS
SELECT col1, col2, computed_col
FROM base_table
WHERE condition
JOIN other_table ON ...;

-- Query it exactly like a table
SELECT * FROM view_name;
SELECT col1 FROM view_name WHERE condition;
SELECT v.col1, t.col2 FROM view_name AS v JOIN other_table AS t ON ...;

-- Replace (update) a view definition
CREATE OR REPLACE VIEW view_name AS
SELECT ...;    -- new definition

-- Drop (delete) a view
DROP VIEW view_name;
DROP VIEW IF EXISTS view_name;     -- no error if view does not exist

-- List all views in the current schema (PostgreSQL)
SELECT table_name FROM information_schema.views
WHERE table_schema = 'public';

// Part 02

The Three Types of Views

Regular View (Standard View)

Data stored?

No — query definition only

Always fresh?

Yes — always executes fresh against base tables

Updatable?

Limited — simple views only

Best for

Simplifying complex joins, access control, abstraction layers

Materialized View

Data stored?

Yes — result physically stored on disk

Always fresh?

No — stale until manually or automatically refreshed

Updatable?

No — read-only snapshot

Best for

Expensive aggregations queried frequently, dashboard caches, pre-computed metrics

Updatable View

Data stored?

No — query definition only

Always fresh?

Yes — always fresh

Updatable?

Yes — INSERT/UPDATE/DELETE pass through to base table

Best for

Simplified DML interface, row-level security enforcement

// Part 03

Creating Your First Views

A simple aggregation view

Create a store revenue view
-- Create the view once
CREATE VIEW vw_store_revenue AS
SELECT
  s.store_id,
  s.store_name,
  s.city,
  s.monthly_target,
  COUNT(o.order_id)                      AS order_count,
  ROUND(SUM(o.total_amount), 2)          AS total_revenue,
  ROUND(AVG(o.total_amount), 2)          AS avg_order_value,
  ROUND(SUM(o.total_amount)
    / s.monthly_target * 100, 1)         AS target_pct
FROM stores AS s
LEFT JOIN orders AS o
  ON s.store_id    = o.store_id
  AND o.order_status = 'Delivered'
GROUP BY s.store_id, s.store_name, s.city, s.monthly_target;

-- Now any query can use it as if it were a table:
SELECT * FROM vw_store_revenue ORDER BY total_revenue DESC;
SELECT city, total_revenue FROM vw_store_revenue WHERE target_pct >= 100;
Loading FreshCart DB…
Ctrl + Enter to run
Loading FreshCart database in your browser…

A customer summary view

Create a customer summary view
CREATE VIEW vw_customer_summary AS
SELECT
  c.customer_id,
  c.first_name || ' ' || c.last_name    AS full_name,
  c.email,
  c.city,
  c.loyalty_tier,
  c.joined_date,
  COUNT(o.order_id)                     AS order_count,
  COALESCE(ROUND(SUM(o.total_amount), 2), 0)  AS lifetime_value,
  MAX(o.order_date)                     AS last_order_date,
  CURRENT_DATE - MAX(o.order_date)      AS days_since_last_order
FROM customers AS c
LEFT JOIN orders AS o
  ON c.customer_id   = o.customer_id
  AND o.order_status = 'Delivered'
GROUP BY
  c.customer_id, c.first_name, c.last_name,
  c.email, c.city, c.loyalty_tier, c.joined_date;

-- Downstream queries are now simple:
SELECT * FROM vw_customer_summary WHERE loyalty_tier = 'Platinum';
SELECT * FROM vw_customer_summary WHERE days_since_last_order > 90;
SELECT city, COUNT(*), AVG(lifetime_value)
FROM vw_customer_summary GROUP BY city;
Loading FreshCart DB…
Ctrl + Enter to run
Loading FreshCart database in your browser…

// Part 04

Querying Views Like Tables

Once a view exists, every SQL construct works on it — WHERE, GROUP BY, ORDER BY, JOINs, subqueries, CTEs. The database executes the view's underlying query and treats the result as the input for any additional clauses you add.

All standard SQL works on views
-- Filter a view
SELECT * FROM vw_customer_summary
WHERE lifetime_value > 1000
  AND loyalty_tier = 'Gold';

-- Aggregate a view
SELECT city, COUNT(*) AS customer_count, AVG(lifetime_value) AS avg_ltv
FROM vw_customer_summary
GROUP BY city
ORDER BY avg_ltv DESC;

-- JOIN two views together
SELECT
  cs.full_name,
  cs.loyalty_tier,
  cs.lifetime_value,
  sr.total_revenue AS store_revenue
FROM vw_customer_summary AS cs
JOIN vw_store_revenue AS sr
  ON cs.city = sr.city    -- customers and their local store revenue
WHERE cs.lifetime_value > 500;

-- Use a view inside a CTE
WITH high_value AS (
  SELECT * FROM vw_customer_summary
  WHERE lifetime_value > 1000
)
SELECT loyalty_tier, COUNT(*) AS count, AVG(lifetime_value) AS avg_ltv
FROM high_value
GROUP BY loyalty_tier;

-- Use a view in a subquery
SELECT * FROM vw_customer_summary
WHERE lifetime_value > (
  SELECT AVG(lifetime_value) FROM vw_customer_summary
);
Loading FreshCart DB…
Ctrl + Enter to run
Loading FreshCart database in your browser…

// Part 05

Layered Views — Building Abstractions

Views can reference other views — creating layers of abstraction where each layer builds on the previous. The base layer joins raw tables. The mid layer computes metrics. The top layer provides the final business view. Changes to the base tables propagate automatically through all layers.

Three-layer view architecture
-- Layer 1: clean base data
CREATE VIEW vw_delivered_orders AS
SELECT *
FROM orders
WHERE order_status = 'Delivered'
  AND order_date >= '2024-01-01';

-- Layer 2: per-store metrics (references Layer 1)
CREATE VIEW vw_store_metrics AS
SELECT
  store_id,
  COUNT(*)                       AS order_count,
  ROUND(SUM(total_amount), 2)    AS revenue,
  ROUND(AVG(total_amount), 2)    AS avg_order
FROM vw_delivered_orders           -- references Layer 1 view
GROUP BY store_id;

-- Layer 3: full store report (references Layer 2 + base tables)
CREATE VIEW vw_store_report AS
SELECT
  s.store_name,
  s.city,
  s.monthly_target,
  m.order_count,
  m.revenue,
  m.avg_order,
  ROUND(m.revenue / s.monthly_target * 100, 1) AS target_pct
FROM stores AS s
JOIN vw_store_metrics AS m ON s.store_id = m.store_id;  -- Layer 2

-- Business query is now trivial:
SELECT * FROM vw_store_report WHERE target_pct >= 80 ORDER BY revenue DESC;
Loading FreshCart DB…
Ctrl + Enter to run
Loading FreshCart database in your browser…

🎯 Pro Tip

Limit view layering to 3 levels maximum. Deep view chains (5+ levels) become hard to debug and optimise — when a query is slow, you have to trace through multiple view definitions to understand what the database is actually computing. Flatten or materialise deep chains where performance matters.

// Part 06

Views for Security — Column and Row Masking

Views are the standard SQL mechanism for column-level and row-level security. Instead of granting access to a base table directly, grant SELECT on a view that exposes only the columns and rows a user should see. The underlying sensitive columns and filtered-out rows are completely invisible.

Column masking — hide sensitive data

Column-level security via views
-- Base table: customers (has sensitive columns)
-- employee_id, first_name, last_name, email, phone, salary, national_id, ...

-- View for customer support team: no salary, no national_id
CREATE VIEW vw_customers_support AS
SELECT
  customer_id,
  first_name,
  last_name,
  -- Email partially masked for support staff
  LEFT(email, 3) || '***@' || SPLIT_PART(email, '@', 2) AS email_masked,
  city,
  loyalty_tier,
  joined_date
  -- salary, national_id, full email EXCLUDED
FROM customers;

-- Grant SELECT only on the view, not the base table:
GRANT SELECT ON vw_customers_support TO support_team_role;
REVOKE SELECT ON customers FROM support_team_role;

-- Support staff query the view — they never see sensitive columns
SELECT * FROM vw_customers_support WHERE customer_id = 42;

Row-level security — filter which rows are visible

Row-level security via views
-- Each store manager should only see their own store's orders
-- In a multi-tenant or role-based system:

CREATE VIEW vw_my_store_orders AS
SELECT *
FROM orders
WHERE store_id = current_setting('app.current_store_id');
-- current_setting() reads a session variable set at login

-- Or for a specific store (simpler, single-tenant):
CREATE VIEW vw_bangalore_orders AS
SELECT o.*
FROM orders AS o
JOIN stores AS s ON o.store_id = s.store_id
WHERE s.city = 'Seattle';

-- Staff in Seattle only have access to this view:
-- SELECT * FROM vw_bangalore_orders
-- They cannot query orders for other cities
Loading FreshCart DB…
Ctrl + Enter to run
Loading FreshCart database in your browser…

// Part 07

Updating Views — CREATE OR REPLACE

View definitions change when business requirements change — a new column added to a base table should appear in the view, a filter condition needs updating, or a new JOIN is required. The safest way to update a view is CREATE OR REPLACE VIEW — it updates the definition atomically without dropping and recreating, preserving any permissions granted on the view.

Updating a view definition
-- Original view
CREATE VIEW vw_store_revenue AS
SELECT store_id, SUM(total_amount) AS revenue
FROM orders WHERE order_status = 'Delivered'
GROUP BY store_id;

-- Later: add order_count and avg_order to the view
CREATE OR REPLACE VIEW vw_store_revenue AS
SELECT
  store_id,
  COUNT(*)                      AS order_count,    -- NEW
  ROUND(SUM(total_amount), 2)   AS revenue,
  ROUND(AVG(total_amount), 2)   AS avg_order       -- NEW
FROM orders WHERE order_status = 'Delivered'
GROUP BY store_id;

-- CREATE OR REPLACE rules:
-- ✓ Can add new columns at the end
-- ✓ Can change column expressions
-- ✓ Can change WHERE conditions
-- ✗ Cannot remove or reorder existing columns (in PostgreSQL)
--   → DROP VIEW + CREATE VIEW required for structural changes

-- ALTER VIEW (rename only in most databases):
ALTER VIEW vw_store_revenue RENAME TO vw_delivered_store_revenue;

Dropping views safely

DROP VIEW with CASCADE
-- Simple drop
DROP VIEW vw_store_revenue;

-- Safe drop (no error if view doesn't exist)
DROP VIEW IF EXISTS vw_store_revenue;

-- CASCADE: also drops views that depend on this view
-- (views that SELECT FROM this view)
DROP VIEW vw_store_revenue CASCADE;

-- Without CASCADE: dropping a view that other views depend on → ERROR
-- PostgreSQL: ERROR: cannot drop view vw_store_revenue
--             because other objects depend on it
-- Solution: DROP CASCADE or drop dependent views first

-- Check what depends on a view before dropping:
SELECT dependent_ns.nspname, dependent_view.relname
FROM pg_depend
JOIN pg_rewrite ON pg_depend.objid = pg_rewrite.oid
JOIN pg_class dependent_view ON pg_rewrite.ev_class = dependent_view.oid
JOIN pg_class source_table ON pg_depend.refobjid = source_table.oid
JOIN pg_namespace dependent_ns ON dependent_view.relnamespace = dependent_ns.oid
WHERE source_table.relname = 'vw_store_revenue';

// Part 08

Materialized Views — Stored Snapshots

A regular view re-executes its query every time it is queried — always fresh, but always paying the computation cost. A materialized view stores the result physically on disk. Queries against it read the stored result — instant, regardless of how expensive the underlying computation is. The tradeoff: the data becomes stale the moment the base tables change, and must be explicitly refreshed.

Materialized view — create, query, refresh
-- Create materialized view (PostgreSQL syntax)
CREATE MATERIALIZED VIEW mvw_monthly_revenue AS
SELECT
  DATE_TRUNC('month', order_date)::DATE  AS month_start,
  store_id,
  COUNT(*)                               AS order_count,
  ROUND(SUM(total_amount), 2)            AS revenue
FROM orders
WHERE order_status = 'Delivered'
GROUP BY DATE_TRUNC('month', order_date), store_id
ORDER BY month_start, store_id;

-- Query it instantly — reads from stored result, not base tables
SELECT * FROM mvw_monthly_revenue WHERE store_id = 'ST001';

-- Refresh manually (re-executes the underlying query, updates stored result)
REFRESH MATERIALIZED VIEW mvw_monthly_revenue;

-- Refresh without blocking reads (PostgreSQL 9.4+)
-- Requires a UNIQUE index on the materialized view
REFRESH MATERIALIZED VIEW CONCURRENTLY mvw_monthly_revenue;

-- Add index on materialized view for fast queries
CREATE INDEX idx_mvw_monthly_revenue_store
  ON mvw_monthly_revenue(store_id);

-- Drop a materialized view
DROP MATERIALIZED VIEW IF EXISTS mvw_monthly_revenue;

When to materialise vs when to use a regular view

"The underlying query runs in under 1 second"
No performance problem to solve — regular view always returns fresh data
Regular view
"The query joins 5 large tables and takes 30 seconds"
Expensive computation queried frequently — materialise and refresh periodically
Materialized view
"Dashboard queries run 1,000 times per day on the same aggregation"
Compute once, serve 1,000 times — huge reduction in database load
Materialized view
"Data must always reflect the most recent transaction"
Materialized view would be stale — freshness requirement rules it out
Regular view
"Monthly historical reports where data does not change after month-end"
Historical data is immutable — materialise once at month-end, never refresh
Materialized view
"Access control — hide sensitive columns from a role"
Security is about query filtering, not performance — regular view is correct
Regular view

// Part 09

Updatable Views — DML Through a View

Simple views — those that select directly from one base table with no aggregation, DISTINCT, LIMIT, or complex expressions — are updatable. INSERT, UPDATE, and DELETE on the view pass through to the base table transparently. More complex views require INSTEAD OF triggers to handle DML.

Updatable view rules and WITH CHECK OPTION
-- A simple updatable view (one table, no aggregation)
CREATE VIEW vw_active_customers AS
SELECT customer_id, first_name, last_name, email, city, loyalty_tier
FROM customers
WHERE loyalty_tier != 'Bronze';   -- row filter

-- UPDATE through the view (updates the base table):
UPDATE vw_active_customers
SET city = 'Austin'
WHERE customer_id = 5;
-- This executes: UPDATE customers SET city = 'Austin' WHERE customer_id = 5

-- INSERT through the view (inserts into base table):
INSERT INTO vw_active_customers (first_name, last_name, email, city, loyalty_tier)
VALUES ('Ananya', 'Reddy', 'ananya@test.com', 'Boston', 'Gold');

-- PROBLEM: a user could INSERT a Bronze customer through the view
-- even though the view filters out Bronze customers
-- WITH CHECK OPTION prevents this:

CREATE OR REPLACE VIEW vw_active_customers AS
SELECT customer_id, first_name, last_name, email, city, loyalty_tier
FROM customers
WHERE loyalty_tier != 'Bronze'
WITH CHECK OPTION;   -- INSERT/UPDATE must satisfy the view's WHERE condition
-- Now: INSERT of a Bronze customer through this view → ERROR
-- "new row violates check option for view vw_active_customers"

Conditions that make a view non-updatable

View containsUpdatable?Why
Single base table, no aggregation✅ YesDML maps directly to the base table
GROUP BY or aggregate functions❌ NoCannot map DML to individual base rows
DISTINCT❌ NoDeduplication prevents row-level DML mapping
UNION / UNION ALL❌ NoMultiple sources — ambiguous which table to modify
Subquery in SELECT list❌ NoComputed columns have no base column to update
JOIN of multiple tables❌ No in most DBsPostgreSQL allows DML on one side with INSTEAD OF triggers
LIMIT / OFFSET❌ NoCannot identify which base row to modify
Window functions❌ NoComputed from multiple rows — no single base row

// Part 10

Views vs CTEs vs Derived Tables — When to Use Each

Views, CTEs, and derived tables all create named intermediate result sets. Choosing between them depends on scope, reuse, and persistence requirements.

ViewCTE (WITH)Derived Table
ScopeDatabase-wide — any query in any sessionCurrent query onlyCurrent query only, single use
PersistenceStored in schema permanentlyTemporary — gone after query endsTemporary — gone after query ends
Reusable across sessions✅ Yes❌ No❌ No
Reusable within one query✅ Yes✅ Yes❌ No (once inline)
Can be indexedMaterialized views only❌ No❌ No
Access control✅ GRANT/REVOKE on view❌ No❌ No
Version-controlledVia DDL migration scriptsEmbedded in queryEmbedded in query
Best forShared reporting logic, access control, API contractComplex multi-step single queryShort single-use pre-aggregation

🎯 Pro Tip

The decision rule: if the same query logic is needed in more than one separate query or by more than one person or application — create a view. If the logic is needed only within a single complex query — use a CTE. If it is a short one-time pre-aggregation — use a derived table inline. Views are team assets; CTEs and derived tables are query-private tools.

// Part 11

What This Looks Like at Work

You are a senior data engineer at BigBasket. The analytics team runs 15 different reports — all of which need the same "delivered order with product details" JOIN across four tables. Currently every analyst copies and pastes the same 20-line JOIN query into their reports. When the schema changes (a new column, a renamed table), every one of those 15 reports breaks and needs updating manually.

10:00 AM
The problem is identified
15 reports all share the same base JOIN. Any schema change breaks all 15. The fix: create a view that encapsulates the join logic. All 15 reports reference the view. Future schema changes update the view definition once and all reports are fixed.
10:20 AM
Design the view hierarchy
Layer 1: vw_delivered_order_lines — full order detail with product info. Layer 2: vw_product_performance — aggregated product metrics. Layer 3: vw_store_product_performance — per-store product breakdown.
Loading FreshCart DB…
Ctrl + Enter to run
Loading FreshCart database in your browser…
Loading FreshCart DB…
Ctrl + Enter to run
Loading FreshCart database in your browser…
11:15 AM
Views created and deployed
The two views replace 15 copy-pasted JOIN queries. Every existing report is updated to reference vw_delivered_order_lines instead of the raw JOIN. The next schema change (adding a discount_amount column to order_items) requires updating only the view definition — all 15 reports benefit immediately without any individual changes.

🎯 Pro Tip

Version-control your view definitions just like application code. Store every CREATE OR REPLACE VIEW statement in a migrations file. When a view definition changes, the migration captures the before and after states. This makes it possible to roll back a view change if it breaks downstream reports, and gives you a full audit trail of how the view has evolved over time.

// Part 12

Interview Prep — 5 Questions With Complete Answers

Q: What is a view in SQL and what are its main use cases?

A view is a named query stored in the database schema. When queried, the database executes the view's underlying query and presents the result as if it were a table. No data is physically stored in a regular view — it is purely a saved query definition. The view is transparent to the query engine: SELECT * FROM vw_store_revenue is exactly equivalent to embedding the view's full query inline.

Three primary use cases. First, abstraction and simplification: complex JOIN logic spanning multiple tables is defined once in a view and referenced by name in all downstream queries. When the schema changes, only the view definition needs updating — all queries that reference it automatically benefit. This is the most common production use of views in data engineering: a team of analysts queries clean, pre-joined views rather than raw base tables.

Second, security: views enforce column-level and row-level access control. A view can expose only a subset of columns (hiding salary, national ID, or full email) and only a subset of rows (filtering to rows belonging to a specific region or store). Granting SELECT on the view and revoking SELECT on the base table means users can only access what the view exposes. Third, API stability: when an application queries a database, the view is the stable API contract. The underlying table structure can change — columns renamed, tables split — without breaking the application, as long as the view is updated to maintain the same output column names and types.

Q: What is the difference between a regular view and a materialized view?

A regular view stores only the query definition — no data is physically stored. Every time the view is queried, the database executes the underlying query against the current base table data and returns the result. The data is always current because it is computed on demand. The cost is that every query against the view pays the full computation cost of the underlying query.

A materialized view stores the result of the query physically on disk — it is a snapshot of the data at the time the view was last refreshed. Queries against a materialized view read the stored result, not the base tables — they are as fast as querying a regular indexed table. The cost is staleness: the stored result becomes out of date the moment the base tables change. Refresh must be triggered manually (REFRESH MATERIALIZED VIEW) or scheduled (via pg_cron or an external scheduler) to update the stored data.

Choose regular view when data freshness is required — financial transactions, real-time inventory, live order status. Choose materialized view when the underlying query is expensive (multi-table aggregation, complex analytics) and is queried frequently, and when slightly stale data is acceptable. A monthly revenue report that is refreshed nightly is a perfect materialized view candidate — the computation runs once at midnight, and the next day's 10,000 dashboard queries each read the stored result in milliseconds instead of recomputing a 5-second aggregation.

Q: Can you INSERT, UPDATE, or DELETE through a view?

Yes, for simple views. A view is updatable when it meets these conditions: it selects from exactly one base table, it contains no GROUP BY or aggregate functions, no DISTINCT, no UNION, no LIMIT, no subqueries in the SELECT list, and no window functions. For updatable views, DML operations on the view are transparently passed through to the underlying base table — UPDATE vw_active_customers SET city = 'New York' WHERE customer_id = 5 executes as UPDATE customers SET city = 'New York' WHERE customer_id = 5.

WITH CHECK OPTION is an important addition to updatable views that have a WHERE filter. Without it, a user could INSERT a row through a view that does not satisfy the view's WHERE condition — the row would be inserted into the base table but immediately invisible through the view. WITH CHECK OPTION prevents this: any INSERT or UPDATE through the view must produce a row that satisfies the view's WHERE filter, or the operation fails with an error.

For complex views (those with JOINs, aggregations, or other features that make automatic DML mapping impossible), PostgreSQL supports INSTEAD OF triggers — trigger functions that intercept DML on the view and execute custom logic to update the appropriate base tables. This allows any view to appear updatable, but requires explicit implementation of the DML logic. In practice, most views that need to be updatable are deliberately kept simple to avoid the complexity of INSTEAD OF triggers.

Q: How do views help with database security?

Views implement two types of access control. Column-level security: a view can SELECT only a subset of columns from the base table, omitting sensitive columns entirely. CREATE VIEW vw_customers_public AS SELECT customer_id, first_name, city FROM customers — no email, no phone, no date of birth. When the support team is granted SELECT on vw_customers_public and has their SELECT permission on the base customers table revoked, they can query customer data but never see sensitive fields. The base table's sensitive columns are completely invisible to them.

Row-level security: a view can include a WHERE clause that limits which rows are visible. CREATE VIEW vw_my_region_orders AS SELECT * FROM orders WHERE region = current_setting('app.user_region') — each session that sets their region variable sees only their region's orders. This is how multi-tenant applications implement data isolation in PostgreSQL before row-level security policies (RLS) were available, and it remains a simple, portable approach.

The security model works by granting USAGE on the schema and SELECT on the view, while explicitly REVOKE-ing SELECT on the base tables. PostgreSQL also supports native Row Level Security (RLS) policies which are more flexible — they attach security rules directly to tables and enforce them automatically regardless of whether the user queries the table directly or through a view. For column masking, views remain the most portable approach across all SQL databases. For row filtering, both views and RLS policies are valid — RLS is preferred for complex multi-role scenarios because it cannot be bypassed by a direct table query as a view-only approach can be (if someone is accidentally granted direct table access).

Q: What happens to a view when its underlying base table changes?

The behaviour depends on the nature of the change. For additive changes (adding a new column to the base table), the view is unaffected — it continues to select its defined columns and the new column simply does not appear in the view unless the view definition is updated to include it. For destructive changes (removing or renaming a column that the view references), the view becomes invalid — querying it will fail with an error like "column does not exist" or "view definition is no longer valid."

PostgreSQL checks view validity lazily — the error appears when the view is queried, not when the underlying table is altered. Some databases (SQL Server, Oracle) track dependencies and can warn or prevent dropping a column that is referenced by a view. In PostgreSQL, you can check for stale views after a schema change by running SELECT definition FROM pg_views and parsing the definitions, or by attempting to query each view in a test environment.

This is exactly why views are valuable as an API stability layer. If you change the base table (split one large table into two, rename a column, add a column that should be computed differently) you update the view definition to maintain the same output structure. Downstream consumers — applications, reports, other views — never know the schema changed. The view absorbs the change. The migration process is: make the base table change, update the view definition (CREATE OR REPLACE VIEW or DROP + CREATE), verify the view still returns the expected output, and the downstream systems are unaffected. Without a view layer, every query that references the renamed column would need to be updated individually.

// Part 13

Errors You Will Hit — And Exactly Why They Happen

ERROR: cannot drop view because other objects depend on it

Cause: Another view (or trigger, or rule) references the view being dropped. PostgreSQL tracks dependencies between views and prevents dropping a view that would invalidate a dependent object. This happens when views are layered — dropping a base-layer view breaks views in higher layers.

Fix: Use DROP VIEW view_name CASCADE to automatically drop the dependent views as well. Before doing so, list what depends on the view: SELECT dependent_ns.nspname || '.' || dependent_view.relname FROM pg_depend JOIN pg_rewrite ON pg_depend.objid = pg_rewrite.oid JOIN pg_class dependent_view ON pg_rewrite.ev_class = dependent_view.oid JOIN pg_class source ON pg_depend.refobjid = source.oid WHERE source.relname = 'your_view_name'. If the dependent views should be preserved, update their definitions to not reference the view being dropped before dropping it.

ERROR: cannot update view — view contains aggregate functions

Cause: DML (UPDATE, INSERT, DELETE) is attempted on a view that contains GROUP BY, aggregate functions (SUM, COUNT, AVG), DISTINCT, or UNION. These features make it impossible for the database to determine which base table rows correspond to the view rows being modified — there is no direct row-level mapping.

Fix: For read-only analytical views, this is expected and correct — they are not meant to be updatable. Remove the DML attempt. If the view genuinely needs to support DML, simplify the view to contain only a single base table with a WHERE filter — no aggregation, no JOINs. For complex views that must support DML, implement INSTEAD OF triggers in PostgreSQL which intercept the DML and execute custom logic to update the appropriate base table rows.

View returns stale data — changes to base table not reflected

Cause: This is a materialized view, not a regular view. Materialized views store a physical snapshot that is not automatically updated when base tables change. A regular view always reflects current base table data; a materialized view reflects data as of the last REFRESH.

Fix: Run REFRESH MATERIALIZED VIEW view_name to update the stored snapshot. For production: schedule REFRESH on a cron job (pg_cron extension in PostgreSQL) at an appropriate frequency — hourly for dashboards, nightly for monthly reports. For REFRESH without blocking concurrent reads, first create a unique index on the materialized view and use REFRESH MATERIALIZED VIEW CONCURRENTLY view_name. If data must always be current, convert to a regular view (DROP MATERIALIZED VIEW, CREATE VIEW with the same definition) — the performance cost of re-executing the query on each access may be acceptable.

ERROR: column 'col_name' of view must be unique — cannot create or replace view

Cause: The view's SELECT list contains two or more columns with the same name (either because two base tables both have a column called 'id', or because two computed expressions were not given aliases). Views require all output column names to be unique.

Fix: Add explicit AS aliases to disambiguate: SELECT a.id AS customer_id, b.id AS order_id FROM customers AS a JOIN orders AS b ON ... All columns that would produce duplicate names must be aliased. Also ensure computed expressions have aliases: SELECT COUNT(*) AS order_count rather than SELECT COUNT(*). Check for ambiguity when using SELECT * in views — if two joined tables share column names, SELECT * will produce duplicates. Explicitly list columns instead of using SELECT *.

View query is very slow — slower than running the underlying query directly

Cause: The query planner is not optimally handling the view — either predicate pushdown is not working (filters on the view are not being pushed into the view's query), or the view nests multiple layers and the planner cannot see through all the abstraction. Also, a materialized view may not have the necessary indexes.

Fix: Use EXPLAIN ANALYZE on SELECT * FROM view_name WHERE condition to see the execution plan. If the filter is not pushed into the inner query, try rewriting as a CTE: WITH v AS NOT MATERIALIZED (view_definition) SELECT * FROM v WHERE condition — NOT MATERIALIZED forces the planner to inline the view definition and push predicates. For deeply nested view chains, consider flattening to a single view or a materialized view. For materialized views, add CREATE INDEX on the columns most commonly used in WHERE and JOIN conditions: CREATE INDEX idx_mv_col ON mv_name(column).

Try It Yourself

Design three views for FreshCart's reporting layer. Write the CREATE VIEW statements AND the verification SELECT queries for each. (1) vw_product_catalogue — a clean product view showing product_id, product_name (Title Case), category, brand, unit_price, cost_price, margin_amount (unit_price - cost_price), margin_pct (rounded to 1dp), price_band ('Budget' <50, 'Standard' <150, 'Premium' <300, 'Luxury' otherwise), and in_stock. (2) vw_customer_segments — customer_id, full_name, city, loyalty_tier, lifetime_value (sum of delivered orders, 0 if none), order_count, last_order_date, and a segment: 'Champion' if lifetime_value > 1500, 'Loyal' if > 500, 'Promising' if order_count > 0, 'New/Inactive' otherwise. (3) vw_daily_sales — order_date, order_count, total_revenue, avg_order_value, unique_customers, and unique_products_sold — for delivered orders only. Then write one query that JOINs vw_product_catalogue to show the top 3 products by margin_pct in each price_band.

🎯 Key Takeaways

  • A view is a named query stored in the database schema. Querying it executes the underlying query transparently — no data is stored in a regular view, only the query definition.
  • Three types: regular view (always fresh, no data stored), materialized view (data stored on disk, must be refreshed, fast to query), updatable view (simple views that allow INSERT/UPDATE/DELETE through to the base table).
  • Views eliminate repeated complex JOIN logic. Define the join once in a view; every report queries the view by name. Schema changes update the view definition once — all downstream queries benefit.
  • Views enforce access control. Grant SELECT on the view, revoke SELECT on the base table. Column-level security: omit sensitive columns. Row-level security: add a WHERE clause to filter rows.
  • CREATE OR REPLACE VIEW updates a view definition atomically without dropping it — preserves all granted permissions. Cannot remove or reorder existing columns without DROP + CREATE.
  • DROP VIEW CASCADE removes the view and all dependent views. Without CASCADE, dropping a view referenced by other views raises an error.
  • Materialized views store query results physically. Use them when the underlying query is expensive and queried frequently and slightly stale data is acceptable. REFRESH MATERIALIZED VIEW updates the stored result.
  • WITH CHECK OPTION on an updatable view prevents INSERT or UPDATE from creating rows that would not be visible through the view's WHERE filter.
  • Views vs CTEs vs derived tables: views are database-wide and persistent — team assets. CTEs are query-scoped — single-query tools. If logic is shared across queries or sessions, create a view.
  • Version-control view definitions in migration scripts alongside table DDL. A view is part of the schema contract — treat changes to view definitions with the same rigour as table alterations.

What comes next

In Module 46, you learn Indexes — how the database finds rows without scanning every row, when to create them, which type to choose, and how to diagnose slow queries with EXPLAIN ANALYZE.

Module 46: Indexes→ Indexes
Share

Discussion

0

Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.

Continue with GitHub
Loading...