Why interviewers love this word: it looks tiny, but it decides whether an index is a rocket or dead weight.
Question: What does cardinality mean in SQL, especially for indexes and performance?
Answer: Cardinality usually means how many distinct values a column has. A column like status has low cardinality because many rows share a few values, while email has high cardinality because most values are unique. This matters because indexes help most when they narrow a search to a small set of rows; if a filter matches too many rows, a table scan can be cheaper.
Interview-Ready Answer: In SQL, cardinality usually means the number of distinct values in a column. It matters for index choice because high-cardinality columns are usually more selective, so an index can jump straight to a small set of rows. Low-cardinality columns like flags or statuses often return a large fraction of the table, so a scan may beat using the index alone. One important detail is that COUNT(DISTINCT ...) ignores NULL in most databases, so you have to think about nulls when measuring it.
Detailed Explanation: In index interviews, cardinality usually means column cardinality: the number of distinct values in one column. Do not confuse this with table cardinality, which can mean the total number of rows. For example, a table with 10 million rows and a status column that contains only pending, paid, and shipped has low column cardinality but high table cardinality.
O(log N) to find the key, but the real cost also includes fetching matching rows.| Cardinality | Example | Typical plan | Index value |
|---|---|---|---|
| Low | status, is_active | Often scan | Weak alone |
| Medium | city | Depends on data | Sometimes useful |
| High | email, order_id | Often seek | Strong |
Rule of thumb: if a predicate returns only a tiny fraction of the table, an index is usually helpful. If it returns a big chunk, the engine may prefer a scan. There is no universal cutoff like 10% for every database; the decision depends on table size, row width, caching, clustering, and the cost of random reads.
(status, created_at).COUNT(DISTINCT col) usually excludes NULL, so the measured cardinality may be lower than the real number of categories your business thinks it has.Time and space notes: A B-tree index usually gives good lookup performance, but every extra index costs storage and write work. Inserts, updates, and deletes must maintain the index, so more indexes can slow writes even when they speed reads. That is why cardinality is not just about whether an index can work; it is also about whether the speedup is worth the maintenance cost.
Memory model: think of an index like a library catalog. If one label points to only a few books, the catalog helps a lot. If one label points to half the library, the catalog is less impressive.
Real-World Story: Imagine a checkout service for an e-commerce app with a 50-million-row orders table. The team adds an index on status because a dashboard needs WHERE status = 'shipped'. It looks smart, but 80% of orders are shipped, so the query still touches millions of rows. On the first busy Monday morning, the admin dashboard slows to a crawl, p95 latency jumps from 120 ms to 3 seconds, and EXPLAIN shows a sequential scan instead of the index.
The fix is to think in cardinality, not just in “has an index / has no index” terms. The team either uses a more selective composite index like (status, created_at) for the report, or switches the report to filter on a high-cardinality field such as order_id or customer_email when looking up a single order. The misunderstanding causes a classic symptom: lots of CPU, lots of I/O, and users seeing endless spinners even though an index exists. The lesson is simple: an index on a low-cardinality column can be real, but still not the right answer for the query pattern.
DROP TABLE IF EXISTS orders;
CREATE TABLE orders (
order_id INTEGER PRIMARY KEY,
customer_email VARCHAR(100) NOT NULL,
status VARCHAR(20),
city VARCHAR(50)
);
INSERT INTO orders (order_id, customer_email, status, city) VALUES
(1, 'amy@example.com', 'shipped', 'Austin'),
(2, 'ben@example.com', 'pending', 'Austin'),
(3, 'cam@example.com', 'shipped', 'Denver'),
(4, 'dan@example.com', 'cancelled', 'Denver'),
(5, 'eve@example.com', 'shipped', 'Austin'),
(6, 'fay@example.com', 'shipped', 'Seattle'),
(7, 'gus@example.com', NULL, 'Seattle'),
(8, 'ivy@example.com', 'shipped', 'Miami');
-- These indexes illustrate the idea:
-- customer_email is high-cardinality, so equality lookups are usually very selective.
-- status is low-cardinality, so an index on status alone may still match many rows.
CREATE INDEX idx_orders_status ON orders(status);
CREATE INDEX idx_orders_email ON orders(customer_email);
-- Basic cardinality checks.
SELECT
COUNT(*) AS total_rows,
COUNT(status) AS non_null_status_rows,
COUNT(DISTINCT status) AS status_cardinality,
COUNT(DISTINCT city) AS city_cardinality,
COUNT(DISTINCT customer_email) AS email_cardinality
FROM orders;
-- Compare columns by distinct-value ratio.
SELECT 'status' AS column_name,
COUNT(DISTINCT status) AS distinct_values,
COUNT(*) AS total_rows,
CAST(100.0 * COUNT(DISTINCT status) / COUNT(*) AS DECIMAL(5,2)) AS pct_distinct
FROM orders
UNION ALL
SELECT 'city' AS column_name,
COUNT(DISTINCT city) AS distinct_values,
COUNT(*) AS total_rows,
CAST(100.0 * COUNT(DISTINCT city) / COUNT(*) AS DECIMAL(5,2)) AS pct_distinct
FROM orders
UNION ALL
SELECT 'customer_email' AS column_name,
COUNT(DISTINCT customer_email) AS distinct_values,
COUNT(*) AS total_rows,
CAST(100.0 * COUNT(DISTINCT customer_email) / COUNT(*) AS DECIMAL(5,2)) AS pct_distinct
FROM orders;
-- Edge case: COUNT(DISTINCT ...) usually ignores NULL.
-- If your business wants NULL to count as its own bucket, make that explicit.
SELECT
COUNT(DISTINCT status) AS status_cardinality_ignoring_null,
COUNT(DISTINCT COALESCE(status, '<<NULL>>')) AS status_cardinality_including_null
FROM orders;
-- A highly selective lookup: this is the kind of predicate a high-cardinality index helps with.
SELECT order_id, customer_email, status
FROM orders
WHERE customer_email = 'amy@example.com';
-- A low-cardinality filter can still be valid, but on large tables it may touch many rows.
SELECT order_id, customer_email, status
FROM orders
WHERE status = 'shipped';Follow-up & Tricky Questions:
(status, created_at) can be much better than status by itself when the second column narrows the result set.COUNT(DISTINCT col) count NULL? Usually no. That is a common gotcha, so if NULL represents a real bucket in your data model, you must count it explicitly.Common Mistakes:
NULL. Correction: COUNT(DISTINCT ...) usually ignores NULL, so measure carefully.Memory Hook: “A good index is a tiny key that opens few doors; a bad one is a key that opens almost every door.”
Cheat Sheet:
NULL handling can change your count and your plan.Practice Tasks:
COUNT(DISTINCT ...) on several columns in one of your tables and rank them from lowest to highest cardinality.