SQL Views · Guide + 20 Interview Questions

Managing Views in SQL: Complete Guide

A SQL view is a named query that you can use like a table. A regular view does not normally store its own copy of the data; when you query the view, the database works with the underlying query and tables. Views are useful for simplifying complex SQL, hiding columns that users should not access, and providing a consistent way to access data without exposing the underlying table structure directly. Depending on how a view is defined, it may also be updatable, while some views cannot be modified directly. This guide covers CREATE VIEW, updatable views, view performance, security, and the difference between regular views and materialized views, using the same customer and order tables throughout the series, followed by twenty questions commonly asked in SQL interviews.

🎯 Key Takeaways
20 Interview Questions Beginner to Advanced Real Examples Updated Oct 7, 2026
Kailas Warade ✓ Verified Author
Data Engineer · Oracle Certified · SQL & PL/SQL
Kailas works with SQL, PL/SQL, Python, and PySpark to build data pipelines. He writes the SQL interview guides at Sankalan Data Tech, focused on practical, real-world answers.
🏆 Oracle Certified 💼 Data Engineer 📝 SQL & PL/SQL Guides 📍 Pune, India
📤 Share:

On This Page

What a View Really Is

A view is a named SQL query that you can use much like a table. You define the query once, give the view a name, and then use that name in your SQL statements. A regular view normally does not store a separate copy of the result data. The database uses the view definition when you query it and works with the underlying tables.

A simple way to think about a view is as a saved way of looking at data. For example, you could create a view that returns only customers from a particular region or combines customers with their orders. When the underlying data changes, querying the view reflects those changes because the view is based on its underlying query.

Views are especially useful in a few common situations. They can simplify complex joins by putting the logic in one place so users do not have to write the same query repeatedly. They can also hide columns that should not be exposed, such as salary or other sensitive information. Another benefit is that a view can provide a consistent interface for applications and reports while the underlying tables remain separate.

There are some limitations to keep in mind. A complex view can be expensive if its underlying query is expensive, because the database still has to process that logic when the view is queried. Not every view is updatable, especially views containing joins, aggregations, or other complex SQL. Views also need to be maintained when the tables and columns they depend on change. Understanding these trade-offs helps you decide when a regular view is appropriate and when a materialized view or another approach makes more sense.

The Sample Data We Use in Every Example

Same customers and orders tables as the rest of the series. Every view in this guide is defined over these two tables, so you can run the examples side by side with the earlier pages.

-- customers table
-- customer_id | customer_name    | region | credit_limit
-- C001        | Alice Corp       | North  | 50000
-- C002        | Bob Traders      | South  | 25000
-- C003        | Charlie Ltd      | North  | 75000
-- C004        | Diana Retail     | West   | 40000
-- C005        | Ethan Supplies   | South  | 55000
-- C006        | Fiona Mart       | West   | NULL
-- C007        | George Wholesale | North  | 80000

-- orders table
-- order_id | customer_id | order_amount | order_status
-- 1001     | C001        | 12000        | Delivered
-- 1002     | C002        | 4500         | Shipped
-- 1003     | C001        | 7500         | Delivered
-- 1004     | C003        | 22000        | Pending
-- 1005     | C002        | 3200         | Delivered
-- 1006     | C004        | 18000        | Shipped
-- 1007     | C001        | 9000         | Delivered

Three things in this data make the views in this guide useful to demonstrate:

  • Fiona Mart has a NULL credit limit, which is exactly the kind of value a view often needs to replace with a default.
  • Three customers have no orders, so a joined view needs LEFT JOIN to keep them visible.
  • Order amounts vary widely, so a view that filters for high-value orders has something real to filter on.

CREATE VIEW — Naming a Query

The CREATE VIEW statement wraps a SELECT statement in a name. Once the view exists, every query that references that name runs the wrapped SELECT.

A simple single-table view

CREATE VIEW customer_contacts AS
SELECT customer_id,
       customer_name,
       region
FROM   customers;

That view exposes three columns from the customers table. The credit_limit column — which might contain sensitive commercial information — is not part of the view, so anyone querying it does not see that data.

SELECT customer_name FROM customer_contacts WHERE region = 'North';

Same result as querying customers directly with those columns, but through a name that reflects what the view is for. The next section explains why that naming matters more than it seems.

A view with a join

CREATE VIEW customer_orders AS
SELECT c.customer_id,
       c.customer_name,
       c.region,
       o.order_id,
       o.order_amount,
       o.order_status
FROM   customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id;

Every customer appears, with their orders attached where they exist. Customers with no orders show up with NULL in the order columns — because the LEFT JOIN keeps them. Without the LEFT JOIN, the three customers with no orders would disappear, and that would surprise anyone who expected the view to show every customer.

This is the classic use case for a view. The join is written once, tested once, and reused by every query that needs the combined result. Six months later, when someone changes the join condition or adds a filter, they change it in one place, and every query that uses the view picks up the change.

A view with aggregation

CREATE VIEW customer_totals AS
SELECT c.customer_id,
       c.customer_name,
       c.region,
       COUNT(o.order_id)     AS order_count,
       SUM(o.order_amount)   AS total_spend
FROM   customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.customer_name, c.region;

One row per customer with their totals. This is the shape most reporting teams end up needing — a summary view that any report can query without re-deriving the aggregation.

On our data, the view returns seven rows. Alice Corp comes back with three orders and a total of 28,500. Charlie Ltd comes back with one order and 22,000. Fiona Mart comes back with zero orders — the COUNT is 0 and the total_spend is NULL, which is how aggregate functions handle an empty set.

Dropping a view

DROP VIEW customer_contacts;

The view is removed from the database. The underlying tables are not affected — the data is still there. Only the named query is gone. Any application that referenced the view by name now fails until the reference is updated.

Inspecting a View

When you inherit a database and need to understand what a view does, or when a view stops working and you need to figure out why, the first step is to see the SQL behind it.

Viewing the definition

-- Oracle, SQL Server (via their system views)
SELECT view_name, text
FROM   user_views
WHERE  view_name = 'CUSTOMER_ORDERS';

-- MySQL, PostgreSQL
SHOW CREATE VIEW customer_orders;

-- PostgreSQL psql shortcut
\d+ customer_orders

Each database exposes the view's SQL through its own system catalog. Oracle stores it in USER_VIEWS. PostgreSQL and MySQL use SHOW CREATE VIEW. PostgreSQL's psql client has a shorthand \d+ that shows both the columns and the definition.

Viewing the column structure

-- MySQL, Oracle
DESCRIBE customer_orders;

-- PostgreSQL
\d customer_orders

That gives you a list of columns, their types, and whether they can be NULL. Same output shape as describing a table, because from the client's perspective a view and a table look almost the same.

Why this matters in practice

The definition is the whole story of a view. If the view stops returning rows, look at the WHERE clause. If the numbers changed, look at the joins. If a query against the view is slow, the definition will show you where the expensive parts are — the joins that run without indexes, the aggregations that scan every row, the subqueries that fire once per row.

Simple Views vs Complex Views

Views are either simple or complex, and the distinction decides what you can do with them. Simple views are updatable; complex views usually are not.

What makes a view simple

  • Single base table (no joins)
  • No aggregate functions — no SUM, COUNT, AVG
  • No GROUP BY, DISTINCT, or HAVING
  • No subqueries in the SELECT list
  • Every column in the view maps directly to a column in the base table
-- Simple view: single table, no aggregates, no expressions
CREATE VIEW customer_contacts AS
SELECT customer_id, customer_name, region
FROM   customers;

That view is updatable. An UPDATE customer_contacts SET region = 'Northeast' WHERE customer_id = 'C001' translates directly to an update against the customers table.

What makes a view complex

-- Complex view: join, aggregate, group by
CREATE VIEW customer_totals AS
SELECT c.customer_id,
       c.customer_name,
       COUNT(o.order_id)   AS order_count,
       SUM(o.order_amount) AS total_spend
FROM   customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.customer_name;

That view is not updatable. If you tried to insert into it, what would the database do? The order_count column is a computed value — it does not correspond to any column in any table. The total_spend column aggregates data across multiple rows. There is no obvious way to translate a write against the view into a write against the base tables.

The general rule: if the view loses the mapping between its columns and the base table's rows, it stops being updatable. Joins multiply rows. Aggregates collapse them. Either way, the direct one-to-one relationship is broken.

INSTEAD OF triggers — the workaround

CREATE TRIGGER trg_customer_totals_insert
INSTEAD OF INSERT ON customer_totals
FOR EACH ROW
BEGIN
    INSERT INTO customers (customer_id, customer_name)
    VALUES (:NEW.customer_id, :NEW.customer_name);
END;

Some databases let you attach an INSTEAD OF trigger to a view. When someone tries to insert into the view, the trigger runs instead of the default behaviour, and you write the logic that decides what to do with the data. That gives you updatability for otherwise non-updatable views, at the cost of having to write the mapping yourself.

INSTEAD OF triggers are supported by Oracle, PostgreSQL, and SQL Server. MySQL does not have them, so MySQL's non-updatable views really are read-only. That distinction matters during migrations — a view that works on Oracle might not accept writes on MySQL without redesign.

Updatable Views and WITH CHECK OPTION

When you write through a view, the database translates your INSERT, UPDATE, or DELETE into operations on the base table. That translation can be automatic (simple views) or blocked entirely (complex views). But even when it works, there is a subtlety about which rows the update is allowed to affect.

The problem WITH CHECK OPTION solves

CREATE VIEW north_customers AS
SELECT customer_id, customer_name, region
FROM   customers
WHERE  region = 'North';

That view shows only North-region customers. Alice Corp, Charlie Ltd, and George Wholesale appear. The other four do not.

Now suppose someone runs:

UPDATE north_customers
SET    region = 'South'
WHERE  customer_id = 'C001';

The database dutifully updates Alice Corp's region in the base table from North to South. The update succeeds. But now Alice Corp no longer matches the view's WHERE clause — she has effectively vanished from the view without any error being raised. The next person to query north_customers will not see her, and the change is invisible unless they know to check the base table.

The fix — WITH CHECK OPTION

CREATE VIEW north_customers AS
SELECT customer_id, customer_name, region
FROM   customers
WHERE  region = 'North'
WITH CHECK OPTION;

Now the same UPDATE fails. The database refuses to make a change that would move a row out of the view's scope. The error message tells you that the update violates the view's condition. Nothing is committed. Alice Corp stays in the North.

WITH CHECK OPTION is not about preventing a specific value — it is about keeping every row that passes through the view still visible in the view after the change. If the change would make the row disappear, the database rejects it.

What WITH CHECK OPTION does not do

  • It does not prevent updates to rows that stay in scope. If Alice Corp's name changes from Alice Corp to Alice Inc, the view still shows her, so the update succeeds.
  • It does not prevent updates to columns outside the view. Columns not exposed by the view cannot be updated through it in the first place.
  • It does not apply to the base table's own direct writes. Someone updating the customers table directly can still move a row out of the North region.

So WITH CHECK OPTION is a rule for the view's own write path. It is useful for views that act as the primary interface for a subset of data — departmental tables, filtered customer lists, region-scoped reports — where keeping the data consistent with the view definition matters.

When to reach for WITH CHECK OPTION: any time a view is used as a write interface and the view has a WHERE clause that defines the subset. Without it, updates can silently move rows out of the view. With it, those updates fail loudly, which is what you want.

Using Views for Security and Access Control

The cleanest way to give someone access to some of a table without giving them access to all of it is to put a view in front of the table. Grant access to the view. Do not grant access to the table.

Hiding sensitive columns

CREATE VIEW customer_directory AS
SELECT customer_id,
       customer_name,
       region
FROM   customers;

GRANT SELECT ON customer_directory TO analyst_role;

The analyst can query the directory and see the customer name and region. They cannot see credit_limit because the view does not include it — and because they have no grant on the customers table itself, they cannot bypass the view to get it.

This is the standard way to expose a table to a team that only needs some of its columns. The commercial team gets a view with customer names and regions. The finance team gets a view with credit limits. The support team gets a view with contact history. Nobody gets direct access to the base table.

Filtering rows by role

CREATE VIEW north_region_customers AS
SELECT customer_id, customer_name, region, credit_limit
FROM   customers
WHERE  region = 'North';

GRANT SELECT ON north_region_customers TO north_manager_role;

The view restricts rows as well as columns. The North region manager can see every column for their region only. The South, East, and West region data is invisible to them — not just hidden in the UI, but genuinely not present in the results of any query they can run.

That is a stronger guarantee than application-level filtering. If the application has a bug and forgets to filter by region, the database still will not let the North manager see South-region data — because the only thing they can access is a view that is already filtered.

Granting and revoking access

-- Grant read access
GRANT SELECT ON customer_directory TO analyst_role;

-- Grant read plus write
GRANT SELECT, INSERT, UPDATE, DELETE ON customer_directory TO manager_role;

-- Revoke when the project ends
REVOKE SELECT, INSERT, UPDATE, DELETE ON customer_directory FROM analyst_role;

You can grant SELECT, INSERT, UPDATE, or DELETE individually on a view, just like on a table. Granting through roles is cleaner than granting to individual users — when someone joins or leaves a team, you change their role membership instead of editing every grant.

Where views fit in a security model

Views are one layer of defence, not the whole story. They are excellent for column and row-level filtering. They are not a substitute for authentication, encryption at rest, auditing, or network-level controls. What they do well is enforce the principle of least privilege at the data layer — the application and the user only ever see what they need to see.

There is one thing to be careful about. If someone has direct access to the base table and also to the view, the view's filtering is only as strong as the weaker of the two grants. Granting SELECT on the view while forgetting to revoke SELECT on the table means the user can go around the view. Always audit grants when setting up a view-based security model.

Maintaining Views as the Schema Evolves

Views are stored queries, and queries reference column names. When a column is renamed or dropped, every view that referenced it breaks. Keeping views in sync with the schema is ongoing work.

Updating a view when a column changes

-- Original view
CREATE VIEW recent_orders AS
SELECT order_id, order_date FROM orders WHERE order_date > '2024-01-01';

-- Column renamed to order_placed_date
CREATE OR REPLACE VIEW recent_orders AS
SELECT order_id, order_placed_date
FROM   orders
WHERE  order_placed_date > '2024-01-01';

CREATE OR REPLACE VIEW is the standard way to update a view. It redefines the view's query without dropping and recreating it, which matters because dropping a view temporarily removes access for anyone using it. On databases that do not support CREATE OR REPLACE (like MySQL in some contexts), you have to drop and recreate, and the brief window where the view does not exist is a real concern for production systems.

What breaks when a table changes

  • Renamed column — the view's SELECT references the old name and fails.
  • Dropped column — same as rename, but the column is gone forever.
  • Changed data type — the view still works, but the type it exposes changes. Downstream queries may need updates.
  • Dropped table — every view that referenced it fails.
  • Added column — nothing breaks. Views are explicit about which columns they select.

Renames and drops are the dangerous changes. Adds are safe. That is one reason some teams avoid renaming columns in production — the old name becomes a synonym-like alias that lives on, and new code uses the new name where possible. Eventually everyone forgets the old name exists and it gets cleaned up.

A maintenance checklist

  • Before any schema change that renames or drops a column, search for views that reference it.
  • Run every view's definition after the schema change to confirm it still works.
  • Update views with CREATE OR REPLACE when the database supports it.
  • Check downstream reports and applications that query the view.
  • Drop views that are no longer used — every view adds a maintenance obligation.

The general approach: treat views as part of your schema, not as disposable objects. Document what each view is for. Review them periodically. When a view stops being used, drop it — dead views are worse than dead columns because they hide the fact that nobody remembers why they were created.

Indexed Views and Materialized Views

A regular view runs its query every time you use it. For a simple query that is fine. For an expensive one — a heavy join with aggregation, a report that scans millions of rows — it means every query against the view pays the full cost. Two features exist to fix that.

Materialized views — the general concept

-- Oracle syntax
CREATE MATERIALIZED VIEW customer_totals_mv AS
SELECT c.customer_id,
       c.customer_name,
       COUNT(o.order_id)   AS order_count,
       SUM(o.order_amount) AS total_spend
FROM   customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.customer_name;

A materialized view stores the result set physically, like a table. When you query it, the database reads the stored rows instead of recomputing the query. It is dramatically faster for expensive queries.

The trade-off is freshness. The stored result is only as up to date as the last refresh. Different databases offer different refresh strategies:

  • On commit — refresh immediately whenever the base tables change. Always fresh, but adds overhead to every write.
  • On demand — refresh manually or on a schedule. Fast reads, but the data can be stale until you refresh.
  • Complete refresh — recompute the whole result. Simple, but slow for large views.
  • Fast refresh — apply only the changes since the last refresh. Complex to set up, fast to run.

Indexed views — SQL Server's version

-- SQL Server
CREATE VIEW customer_totals_idx
WITH SCHEMABINDING AS
SELECT c.customer_id,
       c.customer_name,
       COUNT_BIG(*) AS order_count
FROM   dbo.customers c
JOIN   dbo.orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.customer_name;

CREATE UNIQUE CLUSTERED INDEX idx_customer_totals
ON customer_totals_idx (customer_id);

SQL Server's indexed views work the same way — the unique clustered index stores the view result physically, so subsequent queries read from the stored data. The SCHEMABINDING clause is required because the database needs to guarantee the view definition will not drift out from under the index.

SCHEMABINDING does something important: it prevents the base tables from being altered in ways that would break the view. You cannot drop or rename a column that the view references until you remove the view. That protects the index but also constrains what you can do with the base table.

When to use which

  • Regular view — when the underlying query is cheap enough that recomputing it every time is acceptable, or when the data must always be current.
  • Materialized view — when the query is expensive and the data does not need to be up to the second. Dashboards, reports, summary tables.
  • Indexed view — SQL Server's approach for the same use case, tied to the table schema.

One practical note: materialized views are a real storage cost. A materialized view on a large table takes up nearly as much space as the source table. Set them up when the read performance gain justifies the storage and refresh overhead, not as a default.

Handling NULL in Views

NULLs are one of the reasons views exist — they give you a place to clean up how NULLs are presented without changing the underlying data.

The problem with NULL in reports

SELECT customer_name,
       credit_limit
FROM   customers;

On our data, Fiona Mart comes back with a NULL credit_limit. To a report reader, that looks like missing data. It might mean "not assessed", "no credit", "unknown", or "intentionally blank". The database does not know which.

The view is where you decide. Whatever the underlying business meaning, you pick a display value and use COALESCE to apply it.

Replacing NULL with a default

CREATE VIEW customer_limits AS
SELECT customer_id,
       customer_name,
       COALESCE(credit_limit, 0) AS credit_limit
FROM   customers;

Fiona Mart now shows 0 instead of NULL. The report reads cleanly. Any arithmetic on the column works. Downstream code does not have to special-case the NULL.

The choice of replacement value depends on context. For a numeric column, 0 is usually the right default. For text, a descriptive string like 'Unassigned' is clearer. For dates, either the current date or a sentinel date like '1900-01-01' works — but be consistent about which.

Handling NULL in aggregations

CREATE VIEW customer_totals AS
SELECT c.customer_id,
       c.customer_name,
       COUNT(o.order_id)                     AS order_count,
       COALESCE(SUM(o.order_amount), 0)      AS total_spend
FROM   customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.customer_name;

That view uses COALESCE to turn the NULL total into 0. Customers with no orders come back with a total of 0 instead of NULL. That matters more than it seems — a NULL total in a report often displays as a blank cell, while a 0 displays as 0. Two very different things to the reader.

The same idea applies to COUNT. COUNT(o.order_id) already returns 0 for a customer with no orders — the COUNT of an empty set is 0, not NULL. So the COUNT column does not need wrapping. Knowing which aggregates return 0 and which return NULL is worth remembering: SUM, AVG, MIN, and MAX return NULL for empty sets; COUNT returns 0.

Filtering NULL out

CREATE VIEW customers_with_limits AS
SELECT customer_id, customer_name, credit_limit
FROM   customers
WHERE  credit_limit IS NOT NULL;

The opposite strategy — remove the rows entirely. Sometimes a report only cares about customers who have an assigned limit, and including the ones with NULL just adds noise. A view that filters them out keeps every downstream query simpler.

Which strategy is right depends on what the report should mean. A customer list where missing limits are a data-quality issue: filter them out. A customer list where every customer should appear: replace with a default. Same underlying data, different views.

Enforcing Business Rules in Views

A view is a good place to put business logic that would otherwise be repeated in every query. A discount eligibility rule, a customer tier calculation, a status flag — put it in the view once, and every consumer sees the same answer.

A calculated column

CREATE VIEW customer_tiers AS
SELECT c.customer_id,
       c.customer_name,
       c.credit_limit,
       CASE
           WHEN c.credit_limit >= 75000 THEN 'Platinum'
           WHEN c.credit_limit >= 50000 THEN 'Gold'
           WHEN c.credit_limit >= 25000 THEN 'Silver'
           WHEN c.credit_limit IS NOT NULL THEN 'Bronze'
           ELSE 'Unclassified'
       END AS tier
FROM   customers c;

Every customer gets a tier label based on their credit limit. Charlie Ltd (75,000) and George Wholesale (80,000) are Platinum. Alice Corp (50,000) and Ethan Supplies (55,000) are Gold. Bob Traders (25,000) and Diana Retail (40,000) are Silver. Fiona Mart, with a NULL limit, is Unclassified.

Every report that needs the tier uses the same calculation because they all query the same view. No application-level logic, no copy-paste of the CASE expression. When the business changes the Platinum threshold from 75,000 to 70,000, you update the view, and every report picks up the change.

Enforcing eligibility rules

CREATE VIEW discount_eligible_customers AS
SELECT c.customer_id,
       c.customer_name,
       SUM(o.order_amount) AS total_spend,
       CASE
           WHEN SUM(o.order_amount) > 20000 THEN 'Eligible'
           ELSE 'Not Eligible'
       END AS discount_status
FROM   customers c
JOIN   orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.customer_name;

The view applies the rule "customers who have spent more than 20,000 are eligible for a discount". Every query that reads from the view sees the same status. If someone tries to argue with the number, there is a single source of truth — the view's definition.

On our data, Alice Corp (28,500) and Charlie Ltd (22,000) are eligible. Bob Traders (7,700) and Diana Retail (18,000) are not. Customers with no orders are not in the view at all because the query uses an INNER JOIN — a deliberate choice, since a customer who has never ordered cannot qualify for a loyalty discount.

The trade-off of putting logic in views

Views are convenient places to put business rules, but they are not the only place. Some teams prefer to keep logic in stored procedures or application code so that it can be unit tested more easily. Others put it in views so that every consumer gets the same answer without coordination.

The right choice depends on who consumes the data and how often the rules change. Data that is queried by many tools — BI dashboards, ad-hoc SQL, exported CSVs — benefits from having the logic in the view. Data that is only ever consumed by one application might be simpler to handle in that application's code.

What matters most is consistency. If the discount rule is expressed in three places — the view, the application, and a report — they will eventually disagree. Picking one place and sticking with it is more important than picking the "right" place.

Views Across Multiple Schemas

In a database with several schemas — HR, Sales, Finance, Reporting — views often need to span them. A finance view might need data from both the Finance schema and the Sales schema. A reporting view might aggregate across a dozen different domains. Managing that cleanly is a small project in itself.

The problem with scattered views

Without a strategy, views end up wherever the person who created them happened to be working. A sales view lives in the Sales schema. A similar finance view lives in Finance. Six months later, two views that do almost the same thing exist in two schemas, and nobody is sure which one is authoritative.

The result is duplication and confusion. Changes get applied to one view and not the other. Reports that were supposed to match produce different numbers. Debugging becomes a hunt for which view is "the real one".

A cleaner approach

  • Dedicated schema for shared views. A schema called reporting or shared_views holds every view intended for cross-team use.
  • Consistent naming. Every view in the shared schema starts with a prefix — v_customer_orders, v_monthly_sales. Easy to spot, easy to search.
  • Schema-qualified references. A view that reads from HR uses hr.customers, not just customers. That way, the view works regardless of who queries it.
  • Role-based grants. Grant access to the shared schema, not to individual views. One grant, many views.
  • Documentation per view. Every shared view has a comment or a companion note explaining what it is for and who maintains it.

Cross-schema access

CREATE VIEW reporting.v_customer_summary AS
SELECT c.customer_id,
       c.customer_name,
       c.region,
       SUM(o.order_amount) AS total_spend
FROM   hr.customers c
JOIN   sales.orders  o ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.customer_name, c.region;

GRANT SELECT ON reporting.v_customer_summary TO analyst_role;

The view lives in the reporting schema but reads from hr and sales. The full schema qualification makes it work no matter who runs the query. The grant puts the view in front of the analyst without giving them direct access to either source schema.

Cross-database views

Some databases extend this further — a view in one database can reference tables in another database or even another server. SQL Server calls these linked server queries. Oracle has database links. PostgreSQL has foreign data wrappers. The syntax is database-specific, but the concept is the same: a view that presents data from a remote source as if it were local.

Use them sparingly. Cross-database views are convenient but they hide the cost of moving data across the network. A join that looked fast in a single-database context might suddenly take seconds when one side is remote. Views across database boundaries are best treated as a convenience layer for read-only reporting, not as the foundation of a real-time application.

20 Interview Questions and Answers

These are the view questions that come up most often. Every answer stands on its own — read them in any order, or work through them in sequence to build up the full picture.

🔍

No matching questions found

Try a keyword like CREATE VIEW, materialized, or CHECK OPTION.

A view is a stored query that behaves like a table. You give it a name, and from then on you can query it exactly as if it were a real table — even though nothing is physically stored. Every time you select from a view, the underlying query runs again.

CREATE VIEW customer_contacts AS
SELECT customer_id, customer_name, region
FROM   customers;

After that statement, SELECT * FROM customer_contacts returns the same three columns as querying the customers table directly. The view runs the SELECT every time, and the result reflects whatever the customers table looks like right now.

Three reasons views are used:

  • Simplification. A complex join with ten tables becomes a single name. Anyone writing a report queries the view instead of rebuilding the join.
  • Security. A view can expose only the columns a team should see. The underlying table still has the sensitive columns, but they are invisible to the user who queries the view.
  • Stability. If the underlying schema changes, you update the view. Every query that references the view keeps working, unchanged.

The trade-off is that views recompute their queries on every use. A simple view on a well-indexed table is nearly free. A view that joins ten tables and aggregates across them is expensive on every query — which is why materialized views exist as an alternative.

CREATE VIEW names a query. You write the SELECT you want to save, wrap it in the CREATE VIEW syntax, and give it a name. From that point on, the name refers to the saved query.

CREATE VIEW customer_orders AS
SELECT c.customer_name,
       o.order_id,
       o.order_amount,
       o.order_status
FROM   customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id;

The view customer_orders now exists. Any query that references it gets the joined result. If someone later changes the join condition, they change it in the view definition, and every query that uses the view picks up the change automatically.

A good habit: build and test the SELECT as a standalone query first, then wrap it in CREATE VIEW. The view definition is just that query, so debugging it as a plain query before saving is easier than debugging through the view.

If you need to redefine the view — say the underlying table has changed — use CREATE OR REPLACE VIEW on databases that support it. That redefines the view in place without dropping and recreating, which matters on production systems where a brief window without the view would break downstream applications.

Each database exposes a view's structure and definition through its own system catalog or shortcut command.

-- Oracle, MySQL
DESCRIBE customer_orders;

-- PostgreSQL
\d customer_orders

-- MySQL, PostgreSQL — full definition
SHOW CREATE VIEW customer_orders;

-- Oracle
SELECT view_name, text FROM user_views WHERE view_name = 'CUSTOMER_ORDERS';

DESCRIBE gives you the column list — names, types, and nullability. It looks just like describing a table because from the client's perspective a view is a table.

SHOW CREATE VIEW gives you the full SQL that the view is defined by. This is the interesting one when you are debugging or trying to understand what a view actually does. It shows the joins, the filters, the aggregates — everything.

In a GUI client like SQL Server Management Studio or Oracle SQL Developer, you can also right-click a view in the schema browser and choose "View Definition" to see the same thing without writing the query.

When a view stops returning the right data, or a query against it is suddenly slow, the definition is the first place to look. It tells you exactly what the database is going to execute every time someone uses the view.

If a view is updatable, you write UPDATE, INSERT, or DELETE statements against it just as you would against a table. The database translates the change into an operation on the base table.

-- Simple view on a single table
CREATE VIEW customer_contacts AS
SELECT customer_id, customer_name, region, credit_limit
FROM   customers;

-- Update through the view
UPDATE customer_contacts
SET    credit_limit = 60000
WHERE  customer_id = 'C001';

The update sets Alice Corp's credit limit to 60,000 — in the underlying customers table. Anyone querying the view or the base table sees the new value.

What makes a view updatable

A view is updatable when it has a one-to-one mapping between its rows and the base table's rows. That generally means:

  • Single base table — no joins
  • No aggregate functions or GROUP BY
  • No DISTINCT or HAVING
  • No subqueries in the SELECT list

Break any of those, and the view is read-only. A view that joins customers to orders does not have a single obvious row to update when you change a column — the customer name appears on every order row, and updating one is meaningless.

INSTEAD OF triggers for complex views

CREATE TRIGGER trg_customer_orders_update
INSTEAD OF UPDATE ON customer_orders
FOR EACH ROW
BEGIN
    UPDATE customers
    SET    customer_name = :NEW.customer_name
    WHERE  customer_id = :NEW.customer_id;
END;

On Oracle, PostgreSQL, and SQL Server, you can attach an INSTEAD OF trigger that runs when someone tries to update the view. The trigger contains the logic to propagate the change to the right base tables. That gives you updatability for views that would otherwise be read-only, at the cost of writing the mapping logic yourself.

DROP VIEW removes the view definition from the database. The underlying tables and their data are untouched — only the saved query is gone.

DROP VIEW customer_orders;

After that statement, any query that references customer_orders fails with "object not found". Everything that used the view needs to be updated to reference a different name, or the view needs to be recreated.

The syntax is standard across databases. Oracle, PostgreSQL, MySQL, and SQL Server all use DROP VIEW view_name. Some databases have an optional CASCADE clause that also drops dependent objects — views that reference this view, or stored procedures that use it.

DROP VIEW customer_orders CASCADE;

When to use CASCADE and when not to. On a small schema where you know what depends on what, plain DROP VIEW is safer — it fails loudly if something depends on the view, and you can decide what to do. On a large schema where you are cleaning up an entire subtree of views, CASCADE saves a lot of time.

The cleaner approach in production: search for the view name across your codebase and database before dropping. If you find references, update them first. Then drop the view. That way you do not leave broken references behind, and you do not accidentally cascade away something you needed.

Managing views across multiple schemas is about centralisation and clarity. Without a strategy, views end up scattered — Sales has its own version of a customer view, Finance has another, and nobody is sure which one is authoritative.

Centralise shared views

CREATE SCHEMA reporting;

CREATE VIEW reporting.v_customer_summary AS
SELECT c.customer_id,
       c.customer_name,
       c.region,
       SUM(o.order_amount) AS total_spend
FROM   hr.customers c
JOIN   sales.orders  o ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.customer_name, c.region;

A dedicated reporting schema holds views that span domains. The view reads from hr and sales, qualifies every table explicitly, and lives in one predictable place.

Use a naming convention

Prefix every shared view with v_ or vw_. It makes views obvious in a list of objects and easy to search for. Some teams also use the domain in the name — v_customer_summary, v_monthly_revenue — so the name itself tells you what the view is for.

Grant access through roles

GRANT USAGE ON SCHEMA reporting TO analyst_role;
GRANT SELECT ON ALL TABLES IN SCHEMA reporting TO analyst_role;

Grant access to the schema once instead of granting individual views. New views added later inherit the same access. That is far fewer grants to manage.

Review periodically

Views accumulate. Some are used constantly, others were created for a one-off analysis and never dropped. Every few months, check which views are being queried and drop the ones that are dead. Every unused view is a small maintenance obligation that adds up.

The general principle: put shared logic where everyone can find it, name things consistently, and clean up what is no longer needed. It is the same discipline you would apply to any other database object, but it matters more for views because they so easily multiply without anyone noticing.

WITH CHECK OPTION is a rule on updatable views. It says: any change made through the view must still satisfy the view's WHERE clause. Rows cannot be modified in a way that would move them out of the view's scope.

CREATE VIEW north_customers AS
SELECT customer_id, customer_name, region, credit_limit
FROM   customers
WHERE  region = 'North'
WITH CHECK OPTION;

That view shows only North-region customers. Without WITH CHECK OPTION, the following update would succeed and quietly remove Alice Corp from the view:

UPDATE north_customers SET region = 'South' WHERE customer_id = 'C001';

The base table would have Alice Corp in the South region. The view north_customers would no longer include her. Any query that relied on the view would silently lose her — no error, just missing data.

With WITH CHECK OPTION, the same UPDATE fails. The database refuses to make a change that would move a row out of the view's scope. The error message tells you what happened — the update violates the view's condition. Nothing is committed.

What WITH CHECK OPTION protects against

  • Accidental updates that change a row's category and make it disappear from the view.
  • INSERTs that would create rows outside the view's filter, invisible to anyone using the view.
  • Data drift between what the view purports to show and what the base table actually contains.

What it does not protect against

WITH CHECK OPTION only applies to writes through the view. If someone has direct access to the base table, they can update the region without going through the view, and the row will leave the view's scope. That is why a view's security model usually includes revoking direct access to the base table.

Not every database supports WITH CHECK OPTION on every view. Views with complex joins, aggregates, or subqueries typically cannot have it. It is a rule for views that would be updatable anyway — it adds a constraint on top of the updatability, not in place of it.

Every view supports SELECT. Whether it supports INSERT, UPDATE, and DELETE depends on the view's shape.

SELECT — always allowed

SELECT * FROM customer_orders WHERE region = 'North';

Any query you can write against a table works against a view. Joins, filters, groupings, subqueries — all of them. The view is just a saved query that the outer query wraps around.

INSERT, UPDATE, DELETE — conditional

  • Updatable views — simple views on a single base table. The database translates the write into an operation on the base table.
  • Read-only views — complex views with joins, aggregates, or GROUP BY. The database cannot determine what the change should mean.
  • Partially updatable — some views allow UPDATE and DELETE but not INSERT, or vice versa.

Checking whether a view is updatable

-- Oracle: check the updatable column
SELECT table_name, updatable
FROM   user_views
WHERE  view_name = 'CUSTOMER_ORDERS';

On databases that track this, the system catalog reports whether a view is updatable. If you cannot tell from the schema, the practical test is to try a small UPDATE on a test row and see what happens. If the view is read-only, the database rejects the write with a clear error.

INSTEAD OF triggers — the escape hatch

CREATE TRIGGER trg_customer_orders_delete
INSTEAD OF DELETE ON customer_orders
FOR EACH ROW
BEGIN
    DELETE FROM orders WHERE order_id = :OLD.order_id;
END;

On Oracle, PostgreSQL, and SQL Server, an INSTEAD OF trigger lets you define the behaviour for a write that the database would otherwise reject. The trigger runs instead of the default action and contains the logic you want. That effectively makes read-only views write-capable, as long as you write the trigger.

MySQL does not support INSTEAD OF triggers, so its read-only views really are read-only. That is worth knowing during migrations — a view that works on Oracle may need rewriting for MySQL.

A view is the natural place to encapsulate a complex query that gets reused. Instead of pasting the same ten-line join with aggregation into every report, you save it as a view and query the view.

CREATE VIEW customer_totals AS
SELECT c.customer_id,
       c.customer_name,
       c.region,
       COUNT(o.order_id)     AS order_count,
       SUM(o.order_amount)   AS total_spend,
       MAX(o.order_amount)   AS largest_order
FROM   customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.customer_name, c.region;

That view encapsulates a join, a filter, and three aggregate functions. Any report that needs per-customer totals queries the view — SELECT * FROM customer_totals WHERE total_spend > 20000 — without re-writing the aggregation logic.

The benefits of encapsulating complexity

  • Consistency. Every query that uses the view gets the same calculation. No risk of one report using SUM and another using AVG by mistake.
  • Maintainability. When the calculation changes — say the business decides to exclude cancelled orders — you change one view definition, and every report picks up the change.
  • Onboarding. New developers do not need to understand the underlying join to use the view. They just query it like a table.

Where complexity in views goes wrong

A view that is too complex becomes a black box. If a view joins ten tables and aggregates four of them, debugging a report that uses it means understanding the whole view definition. Nested views — a view that queries another view — make this worse.

The general rule: one view should do one thing. A view that joins customers to orders does that one thing. A separate view that aggregates over the joined result does another. Keeping views focused makes them readable and makes debugging possible when something goes wrong.

-- View 1: the join
CREATE VIEW customer_orders AS
SELECT c.customer_id, c.customer_name, c.region, o.order_amount
FROM   customers c LEFT JOIN orders o ON c.customer_id = o.customer_id;

-- View 2: the aggregation over view 1
CREATE VIEW customer_totals AS
SELECT customer_id, customer_name, region,
       COUNT(order_amount) AS order_count,
       SUM(order_amount)   AS total_spend
FROM   customer_orders
GROUP BY customer_id, customer_name, region;

Each view is short, understandable on its own, and testable in isolation. Composed, they do what the single complex view did — but with far less cognitive load when something needs to change.

When a column is renamed or dropped, any view that referenced it breaks. The database does not automatically fix view definitions when the underlying schema changes — you have to update the view yourself.

-- Original view referencing order_date
CREATE VIEW recent_orders AS
SELECT order_id, order_date
FROM   orders
WHERE  order_date > '2024-01-01';

-- Column renamed to order_placed_date
-- The view now fails at query time

-- Update the view
CREATE OR REPLACE VIEW recent_orders AS
SELECT order_id, order_placed_date
FROM   orders
WHERE  order_placed_date > '2024-01-01';

CREATE OR REPLACE VIEW is the standard mechanism. It redefines the view in place without dropping and recreating — which matters because a brief window without the view would break any application currently using it.

When CREATE OR REPLACE is not available

Some databases or configurations do not support CREATE OR REPLACE VIEW on all view types. When that happens, the pattern is:

  1. DROP VIEW recent_orders;
  2. CREATE VIEW recent_orders AS ...

Between those two statements, the view does not exist. On a busy system, that window — often a few milliseconds — is a real concern. Schedule these updates during low-traffic periods, or wrap them in a transaction if the database supports transactional DDL.

Updating downstream dependencies

When a view's definition changes, anything that uses the view may need updating too. Other views that reference this one. Stored procedures. Application code. Reports. It is not enough to fix the view — the change cascades outward.

The practical approach: before any schema change, identify every view that could be affected. Update them in the right order — bottom-up, from the deepest views to the ones that reference them. Test each layer as you go. That way, when something breaks, you know which change caused it.

Views are the standard way to give someone access to part of a table without giving them access to all of it. You grant access to the view and revoke access to the underlying table.

Hiding sensitive columns

CREATE VIEW public_employee_data AS
SELECT employee_id, employee_name, department
FROM   employees;

GRANT SELECT ON public_employee_data TO analyst_role;
-- Do NOT grant access to the employees table

The employees table might have salary, social security number, and personal contact information. The public_employee_data view exposes only the columns anyone is allowed to see.

Because the analyst only has a grant on the view, they cannot bypass it to reach the underlying table. The security is enforced at the database layer, not in the application's UI.

Filtering rows by role

CREATE VIEW north_region_customers AS
SELECT customer_id, customer_name, region, credit_limit
FROM   customers
WHERE  region = 'North';

GRANT SELECT ON north_region_customers TO north_manager_role;

That view restricts rows as well as columns. The North manager sees every column for their region — and nothing about the other three regions. Any SQL they write is limited to what the view exposes.

Why this is stronger than application filtering

Application-level filtering is fragile. If a developer writes a query that forgets to add WHERE region = 'North', the North manager briefly sees the South's data. The database does not know what the application was supposed to do.

A view-backed security model is defensive. The North manager literally cannot query data outside the North region, because the only object they have access to is a view that filters by region. Application bugs cannot leak data that the database will not return.

Granting and revoking

-- Read-only access
GRANT SELECT ON public_employee_data TO analyst_role;

-- Read and write
GRANT SELECT, INSERT, UPDATE, DELETE ON customer_directory TO manager_role;

-- Revoke when the team moves on
REVOKE ALL ON public_employee_data FROM analyst_role;

Grant through roles rather than individual users. Roles are easier to audit and easier to change — when someone leaves the team, you remove them from the role, and every grant comes off at once.

A view with a join presents the combined data from two or more tables as if it were one table. You write the join once in the view definition, and every query against the view sees the combined result.

CREATE VIEW customer_orders AS
SELECT c.customer_id,
       c.customer_name,
       c.region,
       o.order_id,
       o.order_amount,
       o.order_status
FROM   customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id;

Any query against the view sees the joined result. Someone who wants a customer with all their orders writes SELECT * FROM customer_orders WHERE customer_id = 'C001' and gets Alice Corp with her three orders — no join needed in their query.

Which join type to use

The join type matters, and it is easy to get wrong in a view. An INNER JOIN would exclude the three customers with no orders. Alice Corp, Charlie Ltd, Diana Retail would appear; Ethan Supplies, Fiona Mart, and George Wholesale would not.

A LEFT JOIN keeps every customer, filling the order columns with NULL where there are no orders. That is usually what you want for a customer-centric view — nobody expects a customer list to silently drop the customers who have not ordered.

The same reasoning applies in reverse. An order-centric view would want a LEFT JOIN on the customers side, so orders whose customer_id does not match a real customer still appear. On our data, every order has a valid customer, so the join behaves the same way — but if you later insert an order with a bad customer reference, the join type determines whether it shows up.

Multi-table joins in views

CREATE VIEW order_details AS
SELECT c.customer_name,
       o.order_id,
       o.order_amount,
       o.order_status,
       p.product_name,
       oi.quantity
FROM   customers    c  ON c.customer_id = o.customer_id
JOIN   orders       o  ON c.customer_id = o.customer_id
JOIN   order_items  oi ON o.order_id    = oi.order_id
JOIN   products     p  ON oi.product_id = p.product_id;

Views can chain joins just like regular queries. Each additional join adds more columns and potentially more rows — the same considerations that apply to a plain multi-table query apply here.

One risk with multi-table views: the row count can grow faster than you expect. If a customer has three orders and each order has four items, the customer appears twelve times in the joined view. Anyone who assumed "one row per customer" would get the wrong answer. Views should be named to reflect their grain — a view called customer_orders implies one row per order, not one row per customer.

Views with aggregate functions give you a saved summarization. Instead of writing SUM or AVG with GROUP BY every time you need per-group totals, you write it once in the view.

CREATE VIEW customer_totals AS
SELECT customer_id,
       COUNT(*)          AS order_count,
       SUM(order_amount) AS total_spend,
       AVG(order_amount) AS avg_order_value,
       MAX(order_amount) AS largest_order
FROM   orders
GROUP BY customer_id;

One row per customer, with four summary metrics. Any query that wants per-customer order statistics writes SELECT * FROM customer_totals and gets the aggregation already done.

On our data, Alice Corp has three orders, a total of 28,500, an average of 9,500, and a largest order of 12,000. Bob Traders has two orders, a total of 7,700, an average of 3,850, and a largest order of 4,500. Charlie Ltd and Diana Retail each have one order.

Grouping by more than one column

CREATE VIEW region_status_totals AS
SELECT c.region,
       o.order_status,
       COUNT(*)          AS order_count,
       SUM(o.order_amount) AS total_amount
FROM   customers c
JOIN   orders    o ON c.customer_id = o.customer_id
GROUP BY c.region, o.order_status;

That view groups by two columns — one row per (region, status) pair. On our data, that is more rows than the single-column version because the North region with three statuses has separate rows for Delivered and Pending (there are no North Shipped orders).

What makes these views read-only

Any view that uses an aggregate function is not updatable. You cannot UPDATE a SUM(order_amount) because there is no single row in the base table that corresponds to the aggregate. The database refuses writes to these views.

That is fine for most uses — summary views are for reading. If someone needs to change a value, they update the base table directly, and the next query against the view picks up the change. The view is a read-only lens on live data.

Performance

Aggregated views are slow because they recompute the aggregation on every query. On a table with millions of orders, a query against an aggregated view runs the full GROUP BY every time.

Two mitigations. First, index the underlying table on the group-by columns — orders.customer_id here — so the database can walk the index instead of scanning the table. Second, if the view is used constantly and the data does not need to be up to the second, consider a materialized view that stores the aggregated result and refreshes on a schedule.

A view can do two things that a table cannot: aggregate data so the summary is pre-computed, and format data so it reads cleanly. Both are useful when you want to hand a friendly dataset to a non-technical audience.

Summarization

CREATE VIEW sales_summary AS
SELECT c.region,
       COUNT(DISTINCT c.customer_id) AS customers,
       COUNT(o.order_id)             AS orders,
       SUM(o.order_amount)           AS total_revenue,
       AVG(o.order_amount)           AS avg_order_value
FROM   customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
GROUP BY c.region;

One row per region with five summary metrics. Any report that wants "regional performance at a glance" queries this view. On our data, North has three customers and five orders, South has two customers and two orders, West has two customers and one order.

Formatting

CREATE VIEW customer_directory AS
SELECT customer_id,
       UPPER(customer_name)               AS customer_name,
       COALESCE(region, 'Unassigned')     AS region,
       COALESCE(credit_limit, 0)          AS credit_limit,
       CASE
           WHEN credit_limit >= 75000 THEN 'Platinum'
           WHEN credit_limit >= 50000 THEN 'Gold'
           WHEN credit_limit >= 25000 THEN 'Silver'
           ELSE 'Standard'
       END                                AS tier
FROM   customers;

The view does four things at once. It uppercases the customer name for consistency. It replaces NULL regions with a descriptive label. It replaces NULL credit limits with 0. And it adds a computed tier column based on the credit limit.

Fiona Mart, whose credit limit is NULL, comes back with a limit of 0 and a tier of Standard. Charlie Ltd and George Wholesale come back as Platinum. The view presents a clean, labelled dataset without touching the underlying table.

Why this is often better than formatting in the UI

If the display rules live in the view, every consumer sees the same thing. A CSV export, a BI dashboard, and an ad-hoc query all get the same tier label for the same customer. If the rules lived in a JavaScript front-end, the CSV and the dashboard would drift from the UI as soon as one of them was updated.

Putting display logic in the view also makes it testable. You can query the view and see the labels, without spinning up the front-end. When the business changes the Platinum threshold, you update the view and check the result — no UI deployment needed.

GRANT gives a user or role permission on a view. REVOKE takes it away. You can grant SELECT, INSERT, UPDATE, DELETE, or any combination, just like on a table.

Granting access

-- Read-only
GRANT SELECT ON customer_directory TO analyst_role;

-- Read plus write
GRANT SELECT, INSERT, UPDATE, DELETE ON customer_directory TO manager_role;

The analyst can query the view but not change anything. The manager can read and write — as long as the view is updatable. If the view is read-only, INSERT, UPDATE, and DELETE fail even with the grant in place.

Revoking access

REVOKE SELECT ON customer_directory FROM analyst_role;

REVOKE ALL ON customer_directory FROM former_employee;

Revoking is immediate. The next query the analyst tries against the view fails with a permissions error. No caching, no delay — the database enforces the change instantly.

Granting through roles is better

CREATE ROLE analyst_role;
GRANT SELECT ON customer_directory TO analyst_role;
GRANT analyst_role TO alice;
GRANT analyst_role TO bob;

You grant once, then attach users to the role. When a new analyst joins, you grant them the role — you do not edit every individual grant. When they leave, you revoke the role, and every permission comes off at once.

Role-based grants also make auditing easier. A single query against the database's grant tables shows you exactly what the analyst role has access to. If you had granted permissions to individual users, you would have to review each one separately.

Verifying grants

-- PostgreSQL
SELECT grantee, privilege_type
FROM   information_schema.role_table_grants
WHERE  table_name = 'customer_directory';

-- Oracle
SELECT grantee, privilege
FROM   user_tab_privs
WHERE  table_name = 'CUSTOMER_DIRECTORY';

Every database exposes its grant state through system views. Running these queries periodically — especially after team changes — confirms that only the right people have access. Stale grants are a security risk, and they build up faster than anyone expects.

The one thing to remember

Revoking a grant on a view does not revoke access to the underlying table. If the analyst also has a grant on the customers table, they can still read the columns the view was hiding. View-based security requires revoking the table grant separately, and it is easy to forget.

An indexed view stores the view's result set physically, like a table, so queries against it do not have to recompute the underlying query every time. It is the SQL Server equivalent of a materialized view.

The two-step process

-- Step 1: create the view with SCHEMABINDING
CREATE VIEW customer_totals_idx
WITH SCHEMABINDING AS
SELECT c.customer_id,
       c.customer_name,
       COUNT_BIG(*) AS order_count,
       SUM(o.order_amount) AS total_spend
FROM   dbo.customers c
JOIN   dbo.orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.customer_name;

-- Step 2: create a unique clustered index on the view
CREATE UNIQUE CLUSTERED INDEX idx_customer_totals
ON customer_totals_idx (customer_id);

The first statement creates the view with SCHEMABINDING. The second creates a unique clustered index, which stores the view's result physically. From that point on, queries against the view read the stored data instead of recomputing the aggregation.

What SCHEMABINDING does

SCHEMABINDING binds the view to the schema of the underlying tables. Once the view is bound, you cannot alter the referenced tables in ways that would break the view — you cannot drop a column the view uses, or change its type, without dropping the view first.

That is a real constraint, and it is why SCHEMABINDING exists. Without it, the index built on the view could become invalid if the underlying table changed. The binding is what makes the index safe.

Naming: COUNT_BIG vs COUNT

In the example above, I used COUNT_BIG(*) instead of COUNT(*). SQL Server requires COUNT_BIG in indexed views because COUNT returns an integer and could overflow on a large table. COUNT_BIG returns a bigint and avoids the problem. It is a small detail that trips people up the first time they try an indexed view with a count.

The cost of indexed views

Every write to the underlying tables now has to also maintain the index. An INSERT into orders is followed by an update to the indexed view. On a table with heavy write traffic, that doubles the write cost.

The trade-off is exactly the same as adding a regular index: reads get faster, writes get slower. Indexed views extend the trade-off to the whole result of a query, not just a single column. Use them when reads dominate and writes can tolerate the extra cost.

Support across databases

SQL Server has indexed views natively, as shown above. Oracle and PostgreSQL use materialized views instead — same concept, different mechanism. MySQL does not support materialized views or indexed views directly; the equivalent requires creating a summary table and refreshing it manually or via triggers.

A materialized view stores its result physically, so reads are fast. The cost is that the stored result can be stale — it only reflects the underlying tables as of the last refresh.

Creating a materialized view

-- Oracle
CREATE MATERIALIZED VIEW customer_totals_mv
REFRESH COMPLETE ON DEMAND
AS
SELECT customer_id,
       COUNT(*)          AS order_count,
       SUM(order_amount) AS total_spend
FROM   orders
GROUP BY customer_id;

That creates a materialized view that refreshes on demand — you run DBMS_MVIEW.REFRESH('CUSTOMER_TOTALS_MV') or the equivalent when you want to update the stored result.

Refresh strategies

  • On commit — the view refreshes immediately whenever the base tables change. Always fresh, but adds overhead to every write.
  • On demand — the view refreshes when you tell it to, either manually or on a scheduler. Cheap writes, potentially stale reads.
  • Complete refresh — recompute the whole result. Simple, but slow on large views.
  • Fast refresh — apply only the changes since last refresh. Complex to configure, but very fast to run when it is set up.

Choosing a refresh strategy

For a report that runs hourly, an on-demand refresh every thirty minutes keeps the view reasonably fresh without hammering the source tables. For a dashboard that needs to be up to the second, on-commit is the only option — accept the write overhead and plan the schema accordingly.

Fast refresh is the ideal for large views. The view is refreshed incrementally, reading only the changed rows. Getting fast refresh to work requires the base tables to have materialized view logs — additional tables that track changes. The setup is fiddly, but once done, refreshes are cheap.

Indexing materialized views

CREATE INDEX idx_mv_customer ON customer_totals_mv (customer_id);

A materialized view is a physical table, so you can index it just like any other table. Indexing the columns that queries filter on makes the view even faster — the query finds the row in the index without scanning the stored result.

When materialized views are worth it

The decision is entirely about the read-write balance. A materialized view is worth it when a query is expensive, the data does not need to be up to the second, and the view is queried often enough that the storage cost is justified.

Dashboards that aggregate over millions of rows. Reports that run every morning on yesterday's data. Analytics that can tolerate a few hours of staleness. All of those are good candidates. A report that needs the latest order that was placed thirty seconds ago is not — the refresh would have to be on-commit, and by then you have lost the performance advantage.

Storage considerations

A materialized view takes up real space. On a table with a hundred million rows, a materialized view over the whole table is nearly as large as the table itself. If you have several materialized views on the same data, the storage can add up fast.

One strategy: use materialized views only over the subset of data that is frequently queried. A view that summarises the last year of activity is much smaller than one over the full history, and it covers most reporting needs.

This is a slightly confusing term to untangle, because CASCADE in the context of views does not mean the same thing as WITH CHECK OPTION. They are two different mechanisms.

What WITH CHECK OPTION actually does

As covered earlier, WITH CHECK OPTION restricts writes through a view to rows that still satisfy the view's WHERE clause. That is the mechanism that keeps a view "consistent" with its own definition — nothing written through the view leaves the view.

CREATE VIEW north_customers AS
SELECT customer_id, customer_name, region
FROM   customers
WHERE  region = 'North'
WITH CHECK OPTION;

That view stays consistent because no write through it can move a row out of the North region.

What CASCADE means for views

CASCADE appears in two contexts related to views. First, on DROP VIEW:

DROP VIEW customer_orders CASCADE;

That drops the view and any other objects that depend on it — other views, possibly stored procedures. Without CASCADE, the drop fails if anything depends on the view.

Second, CASCADE can appear in foreign key definitions and affects how changes propagate between parent and child tables. When a customer is deleted and the orders foreign key has ON DELETE CASCADE, the orders go too. Views that join these tables see the change immediately — the next query reflects the cascade.

How to keep a view consistent with its data

Three techniques, depending on what "consistent" means for your case:

  • WITH CHECK OPTION — keep writes through the view inside the view's filter.
  • Foreign key cascades — ensure that related tables do not get out of sync when a parent row is deleted or updated.
  • Triggers — enforce custom consistency rules that neither of the above can express, in response to insert, update, or delete events.

The combination of these three covers most consistency scenarios. Use WITH CHECK OPTION for view-scoped writes, use foreign key cascades for relational integrity, and use triggers for anything that needs custom logic.

The point worth remembering

Views do not enforce any consistency by themselves. A view is just a saved query — it shows whatever the underlying tables contain at the moment you query it. All of the consistency mechanisms are separate features (CHECK OPTION, foreign keys, triggers) that keep the underlying data clean. The view simply reflects the current state.

If someone asks "how do you make a view consistent", the answer is usually: you make the underlying data consistent, and the view automatically reflects it.

NULL handling in views is about presentation. The underlying data has NULLs — that is a fact. The view is where you decide how those NULLs should appear to whoever queries the view.

Replacing NULLs with a display value

CREATE VIEW customer_limits AS
SELECT customer_id,
       customer_name,
       COALESCE(credit_limit, 0) AS credit_limit,
       COALESCE(region, 'Unassigned') AS region
FROM   customers;

Fiona Mart, whose credit limit is NULL, comes back with 0. Her region — the West region is not NULL for her, but if it were, it would come back as "Unassigned".

The choice of replacement value depends on meaning. Zero for numeric columns where "missing" and "no value" are effectively the same. A label like "Unassigned" or "Not Set" for text columns. A sentinel date for date columns.

Handling NULL in aggregations

CREATE VIEW customer_totals AS
SELECT c.customer_id,
       c.customer_name,
       COUNT(o.order_id)                AS order_count,
       COALESCE(SUM(o.order_amount), 0) AS total_spend
FROM   customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.customer_name;

The COALESCE around SUM turns the NULL that a LEFT JOIN produces for customers with no orders into 0. Fiona Mart, Ethan Supplies, and George Wholesale — the three customers with no orders — now show a total_spend of 0 instead of NULL.

The same does not apply to COUNT(o.order_id). A COUNT of an empty set is 0, not NULL, so it does not need wrapping. Knowing which aggregates produce 0 for empty sets (COUNT) and which produce NULL (SUM, AVG, MIN, MAX) is worth remembering.

Filtering NULL out of the view

CREATE VIEW customers_with_limits AS
SELECT customer_id, customer_name, credit_limit
FROM   customers
WHERE  credit_limit IS NOT NULL;

The opposite strategy. Sometimes the right answer is not to replace the NULL but to exclude the row entirely. A report that only cares about customers with an assigned limit can use this view and not worry about NULL rows at all.

Why this matters more than it seems

A NULL in a report is often displayed as a blank cell, while a 0 shows as 0. Two very different things to the reader. A missing value looks like an oversight; a zero looks like a fact. Deciding which interpretation is correct is a business decision, not a technical one — and the view is where you make that decision explicit.

One warning: once a view replaces NULLs with defaults, downstream queries cannot tell the difference. If a customer's credit limit is 0, is that because it really is 0, or because it was NULL and the view substituted 0? If the distinction matters, do not replace the NULL in the view — leave it visible and handle it in the consuming code. Views are convenient, but they can hide information you eventually need.

A view can calculate and expose business rules without changing the underlying data. The rule lives in the view definition, and every consumer sees the same answer.

A discount eligibility rule

CREATE VIEW discount_eligible AS
SELECT c.customer_id,
       c.customer_name,
       SUM(o.order_amount) AS total_purchases,
       CASE
           WHEN SUM(o.order_amount) > 500 THEN 'Eligible'
           ELSE 'Not Eligible'
       END AS discount_status
FROM   customers c
JOIN   orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.customer_name;

Every customer in the view gets a discount status based on their total purchases. Any report, dashboard, or application that needs to know who is eligible for the discount queries this view and gets the answer directly.

On our data, every customer with orders would be Eligible because all their totals exceed 500. The rule is a bit contrived for this dataset, but the pattern is what matters — a business rule expressed once, in one place.

Why this is better than application-level logic

Imagine the discount rule is implemented in three places: a customer-facing website, an internal admin tool, and a monthly report. If the rule changes — the threshold moves from 500 to 1,000 — three codebases have to change. Miss one, and the customer-facing site and the report disagree about who gets a discount.

Put the rule in a view, and there is one place to change it. Every consumer reads from the view and gets the same answer. The threshold moves from 500 to 1,000 in one view definition, and every report automatically updates.

More complex rules

CREATE VIEW customer_pricing AS
SELECT c.customer_id,
       c.customer_name,
       c.region,
       c.credit_limit,
       CASE
           WHEN c.credit_limit >= 75000 AND c.region = 'North' THEN 0.15
           WHEN c.credit_limit >= 50000 THEN 0.10
           WHEN c.credit_limit >= 25000 THEN 0.05
           ELSE 0.00
       END AS discount_rate
FROM   customers c;

More elaborate rules work the same way. The view computes a discount rate based on credit limit and region. Every quote, every invoice, and every pricing tool can query the view to get the rate for a given customer.

The trade-off of putting business rules in views

Views are a convenient place for business rules, but not the only place. Some teams prefer to keep rules in application code where they can be unit-tested more naturally. Others keep them in the database so all consumers see the same result.

The choice depends on who consumes the data. If the rule only matters to one application, keeping it in that application's code is simpler. If the rule is used by many tools — a website, a BI dashboard, a report, an export — putting it in a view means one source of truth.

What matters most is picking one place and sticking to it. If the rule exists in the view and in application code and in a report template, they will eventually disagree, and the bug will be invisible until someone notices the numbers are different. Consistency beats cleverness here.

Mistakes I Keep Seeing in Interviews

These come up repeatedly. None of them are exotic, and every one of them is easy to avoid once you know it is coming.

Mistake one: assuming views store data. They do not. Every query against a view re-runs the underlying query. That is why a slow view is slow on every use, and why the data is always up to date (unlike a materialized view).
Mistake two: trying to INSERT or UPDATE through a complex view. Views with joins, aggregates, GROUP BY, or DISTINCT are usually not updatable. The database rejects the write, and the fix is either a simpler view or an INSTEAD OF trigger.
Mistake three: forgetting WITH CHECK OPTION on a filtered view that is used as a write interface. Without it, updates can silently move rows out of the view. The row does not error — it just disappears.
Mistake four: granting access to the view but not revoking access to the underlying table. The user goes around the view to the table and sees everything the view was supposed to hide. View-based security requires revoking the table grant too.
Mistake five: renaming or dropping a column without checking the views that reference it. Every view that used the old column fails at query time. Views are invisible to schema-change tooling unless you remember to include them.
Mistake six: nesting views too deep. A view that queries a view that queries another view is a debugging nightmare — one failing query, three possible causes, and no easy way to trace which layer broke. Keep views shallow and single-purpose.

Follow-Up Questions Interviewers Ask

Getting the first answer right is only half the interview. What usually decides the outcome is what happens next. Here is what to prepare for on the views topics.

After "What is a view?" — expect

  • "How is a view different from a table?"
  • "Does a view store data?"
  • "How is a view different from a materialized view?"
  • "Does a view improve performance?"

After "What makes a view updatable?" — expect

  • "Which kinds of views cannot be updated?"
  • "What is an INSTEAD OF trigger?"
  • "What does WITH CHECK OPTION do?"
  • "How do you update a view that joins two tables?"

After "How do you use views for security?" — expect

  • "What is the difference between a view and row-level security?"
  • "How do you revoke access to the base table?"
  • "What happens if someone has access to both the view and the table?"
  • "How do you audit who has access to a view?"

After "What is a materialized view?" — expect

  • "How does the refresh work?"
  • "What is fast refresh vs complete refresh?"
  • "Does the materialized view take up storage?"
  • "When would you use a regular view instead?"

After "How do you handle NULL in a view?" — expect

  • "What does COALESCE do?"
  • "Which aggregates return NULL vs 0 for an empty set?"
  • "Should NULLs be replaced or filtered out?"
  • "Can a view enforce a NOT NULL rule?"

For every question above, prepare a second-level answer. The candidates who pass are not the ones who memorised the first answer — they are the ones who kept going for another thirty seconds and said something useful about why it matters.

Quick Quiz

Three questions to check whether the topics above have landed.

1. Does a regular view store data?
2. What does WITH CHECK OPTION do?
3. What is the difference between a view and a materialized view?