Introduction
When a table holds millions of rows yet most queries touch only a small, well-defined subset, a traditional non-clustered index feels like using a searchlight to find something sitting under a desk lamp: the index still stores an entry for every row even though the workload rarely needs most of them. SQL Server’s answer is the filtered index—introduced in SQL Server 2008 and still under-used today—allowing you to index just the rows that match a predicate you supply in a WHERE
clause.
What a filtered index actually is
Internally a filtered index is simply a regular non-clustered index whose leaf level contains only the rows that satisfy the filter predicate. That predicate can reference any deterministic, non-computed column in the table and can combine multiple columns with standard Boolean operators. SQL Server also maintains a set of statistics scoped to that filtered subset, so the optimiser can estimate cardinality far more accurately than it can with a full-table index plus residual predicate. The result is leaner storage, faster seeks, and often a simpler plan shape because the engine can avoid scanning or re-checking unwanted rows.
Why it helps
Smaller footprint means less I/O during index maintenance and fewer pages in memory while the query runs. That has a cascading effect: buffer-cache pressure falls, latch contention on the index drops, and log writes during updates shrink because fewer rows participate. The filtered statistics improve plan quality, especially on highly skewed data where a general histogram is too coarse. Finally, filtered indexes lend themselves to targeted covering strategies: you can include only the columns needed by the subset of queries that actually use the index instead of bloating a wider structure that must serve every access pattern.
Designing the predicate
A filtered index pays off only when its predicate aligns exactly with the predicates in your queries. For instance, imagine a Sales.Orders
table in which closed orders outnumber active ones fifty to one but dashboards and operational reports read only the active set. The ideal filter here is WHERE Status = 'Active'
. If you sometimes parameterise Status
and at other times hard-code it, parameter sniffing can produce plans that do not use the filtered index, so consistency in query patterns matters. The predicate must also be stable enough that data churn inside the filtered set stays low; otherwise the maintenance savings disappear.
Creating a filtered index: a worked example
/* Sample workload: open orders queried far more often than closed ones */
USE AdventureWorks2022;
GO
/* Baseline query without the filtered index */
SET STATISTICS IO ON;
GO
SELECT SalesOrderID, OrderDate, CustomerID
FROM Sales.SalesOrderHeader
WHERE OrderStatus = 1; -- 1 = Open
GO
/* Build the filtered index */
CREATE NONCLUSTERED INDEX IX_SalesOrderHeader_OpenOrders
ON Sales.SalesOrderHeader (OrderStatus) -- key column
INCLUDE (SalesOrderID, OrderDate, CustomerID) -- covering columns
WHERE OrderStatus = 1; -- filter predicate
GO
/* Rerun the same query to observe the difference */
SELECT SalesOrderID, OrderDate, CustomerID
FROM Sales.SalesOrderHeader
WHERE OrderStatus = 1;
GO
SET STATISTICS IO OFF;
After the index is created the execution plan should switch from a full non-clustered index scan—or even a clustered scan if no suitable index existed—to a seek that touches only the handful of pages holding open orders. STATISTICS IO
now reports dramatically fewer logical reads. Storage drops as well; on a table where ninety-five percent of orders are closed, the filtered index might weigh in at one-twentieth the size of an equivalent unfiltered one.
Updating data and statistics
Because the index tracks only rows that satisfy its predicate, inserts and deletes outside the filter have no effect on it. Updates that move a row in or out of the filtered set, however, translate into delete/insert pairs under the hood, so bursty transitions can inflate fragmentation. Standard index-maintenance commands (ALTER INDEX … REORGANIZE
or … REBUILD
) work just as they do for any non-clustered index. Statistics update automatically unless you have disabled auto-stats; if you manage stats manually remember that UPDATE STATISTICS
without the WITH FULLSCAN
hint samples only the filtered subset.
Limitations and edge cases (explained more plainly)
A few hard rules and gotchas keep filtered indexes from being a silver-bullet solution:
- They must be non-clustered. You can’t turn your clustered index into a filtered one; the feature only works on secondary (non-clustered) indexes.
- Key columns can’t use LOB types. If a column is
VARCHAR(MAX)
,NVARCHAR(MAX)
,VARBINARY(MAX)
,XML
, or another large-object type, it cannot sit in the key of the filtered index. You may still be able to put that column in theINCLUDE
list, because includes don’t participate in ordering. - No filtered index on a view. Indexed views ignore filter predicates entirely, so the optimiser won’t create or use a filtered index there.
- Not eligible for foreign-key targets. Another table can’t point a foreign key at a filtered index. If you need referential integrity, the primary key (or some other unfiltered unique index) has to handle it.
- Ignored by partition switching and indexed-view maintenance. When you switch partitions in or out, or when SQL Server updates an indexed view, it will skip any filtered indexes you created on the underlying table.
- Care needed with
OR
logic. A query that saysWHERE Status = 'Open' OR Status = 'Pending'
will not use the filtered index that covers onlyStatus = 'Open'
, because the predicate no longer matches exactly. Ranges (<
,>
,BETWEEN
) are fine as long as the range fits inside the filter. - Parameter-sensitive plans can still bite you. If one stored procedure sometimes runs for
Status = 1
and sometimes forStatus = 5
, the first execution decides whether the filtered index is used. To keep performance predictable you may need separate procedures,OPTION (RECOMPILE)
, orOPTIMIZE FOR
hints so each run gets its own plan.
Keep these constraints in mind while designing the index and writing queries, and you’ll avoid most surprises.
Conclusion
Filtered indexes provide a precise, low-overhead path to accelerating workloads that focus on narrow slices of large tables. By storing only the necessary rows and building statistics on exactly the data you care about, they combine lower maintenance cost with better plan quality. The key to unlocking their value is matching the filter to a stable, highly selective predicate in your production queries and ensuring that the code paths calling those queries remain consistent. Done well, the payoff is immediate: lighter I/O, leaner memory usage, and faster response times—without redesigning the table or rewriting existing indexes.
If there is the foreigng key on a column that is nullabe. Will filtered index with a predicate Column IS NOT NULL be supporting for DML operations checking foreign key value existence? Thanks.
Yes. On the child table, SQL Server will use a nonclustered filtered index defined with WHERE ForeignKeyCol IS NOT NULL to speed up the internal existence checks during INSERTs, UPDATEs, or parent DELETEs, since only non-NULL values require enforcement. However, the parent table’s key must still be backed by an unfiltered primary key or unique index, because filtered indexes cannot serve as the target of a foreign-key constraint.