SQL Server Catalog Views: sys.tables, sys.indexes, sys.objects
Three catalog views, sys.objects, sys.tables, and sys.indexes, answer most day-to-day questions about what a database contains. This script joins them with sys.schemas and sys.partitions into one inventory: every user table, its schema, its dates, its row count, and each index defined on it. The row count comes from a per-table subquery, so nonclustered index rows repeat the table's figure instead of showing NULL.
Purpose and Overview
sys.objects is the base catalog view for schema-scoped objects: it contains a row for each user-defined, schema-scoped object created within a database, identified by an object_id that is unique within that database. One exclusion matters when you inventory triggers: sys.objects does not show DDL triggers because they are not schema-scoped. All triggers, DML and DDL, are found in sys.triggers.
sys.tables is a derived view. The pair relates like this: "The sys.tables catalog view returns the columns that are specific to tables and also all the columns that the sys.objects catalog view returns." After a table is created, its metadata appears in both views, but there is only one metadata entry with one name and one object_id. In catalog-view terms, sys.objects is a base view with a subset of columns and a superset of rows, and sys.tables is a derived view with a superset of columns and a subset of rows. sys.indexes then holds a row per index or heap of a tabular object, tied back to the table by object_id.
Because all three views share the object_id key, they join without any translation. The script below uses that to build a per-index inventory that works as a starting point for capacity reviews, index audits, and database documentation. Catalog views are the preferred route because they are "the most general interface to the catalog metadata".
The sys views sit beside the INFORMATION_SCHEMA data-dictionary queries, and they replace the SQL Server 2000 system tables covered in sysindexes vs sys.indexes; sys.sysindexes is only a backward-compatibility view, and the current system views are the ones to use instead. For index type, fill factor, or partition data, the sys views carry the columns: sys.indexes exposes type_desc and fill_factor, and sys.partitions exposes data_compression_desc.
Code Breakdown
The query lists every user table with its indexes and a table-level row count. Run it in the context of the database you want to inventory.
1SELECT
2 s.name AS schema_name,
3 t.name AS table_name,
4 t.object_id,
5 t.create_date,
6 t.modify_date,
7 tr.row_count,
8 i.index_id,
9 i.name AS index_name,
10 i.type_desc AS index_type,
11 i.is_primary_key,
12 i.is_unique,
13 i.is_disabled
14FROM sys.tables AS t
15INNER JOIN sys.schemas AS s
16 ON t.schema_id = s.schema_id
17INNER JOIN sys.indexes AS i
18 ON t.object_id = i.object_id
19OUTER APPLY (
20 SELECT SUM(p.rows) AS row_count
21 FROM sys.partitions AS p
22 WHERE p.object_id = t.object_id
23 AND p.index_id IN (0, 1)
24) AS tr
25WHERE t.is_ms_shipped = 0
26 AND i.is_hypothetical = 0
27ORDER BY s.name, t.name, i.index_id;
The driving view: sys.tables
The query starts from sys.tables rather than sys.objects because it wants user tables only. sys.tables returns "a row for each user table", so no type = 'U' filter is needed. The predicate t.is_ms_shipped = 0 then leaves out objects created by an internal SQL Server component, which is how that column is defined. create_date and modify_date are inherited from sys.objects. modify_date is the date the object was last modified by an ALTER statement, and for a table or view it also changes when an index on it is created or altered. Treat it as a hint that something about the table changed, not as proof that the column list changed.
Resolving the schema with sys.schemas
sys.tables exposes schema_id, an integer. Joining to sys.schemas turns it into a readable name. sys.schemas has one row per database schema, with name and schema_id both unique within the database, and it requires only membership in the public role. The SCHEMA_NAME(t.schema_id) function would also work. The explicit join keeps the relationship visible and lets you filter or sort on other schema columns, such as the owning principal_id, without a second lookup.
Indexes and heaps from sys.indexes
The join to sys.indexes produces one row per index. index_id is the discriminator: 0 is a heap, 1 is a clustered index, and anything greater than 1 is a nonclustered index. type_desc spells the same thing out as HEAP, CLUSTERED, NONCLUSTERED, and the other index types (XML, spatial, columnstore, and so on). is_primary_key and is_unique_constraint mark indexes that back constraints, and is_unique marks unique indexes. For a heap, name is NULL, so the heap row appears with no index name. The is_hypothetical = 0 predicate drops hypothetical indexes, which are indexes that "can't be used directly as a data access path" and that hold only column-level statistics. is_disabled is returned so a disabled index shows up instead of hiding among the live ones.
Row counts from sys.partitions
sys.indexes stores no row counts, so the script reads sys.partitions. That view holds one row for each partition of all the tables and most types of indexes in the database, and every table and index has at least one partition, whether or not it is explicitly partitioned. The rows column is "the approximate number of rows in this partition".
Two details shape the subquery. First, the heap (index_id 0) or the clustered index (index_id 1) carries the table's data rows, so the filter p.index_id IN (0, 1) selects the right partitions. Second, a partitioned table has several partition rows for the same index, so the subquery sums them with SUM(p.rows) and returns one number per table. Putting that filter in the ON clause of a plain LEFT JOIN to sys.partitions would leave the count NULL on every nonclustered index row, and a partitioned table would produce one output row per partition. OUTER APPLY avoids both problems, and a table with no partition rows would still appear with a NULL count.
Key Benefits and Use Cases
- Single-query inventory. Every user table, its schema, and all its indexes in one result set that exports cleanly to documentation or a spreadsheet.
- Index-audit starting point. Rows with
index_type = 'HEAP'show tables without a clustered index, and a quickGROUP BYontable_nameshows tables that carry many nonclustered indexes. - Capacity review. Approximate row counts per table, without running
sp_spaceusedone table at a time. - Change tracking.
create_dateandmodify_dategive a cheap way to find tables touched since the last release. - Disabled-index check.
is_disabledsurfaces indexes that a maintenance job or deployment left switched off. - Foundation for richer scripts. The
object_idjoin pattern extends tosys.columns,sys.index_columns, and the rest of the object catalog views.
Performance Considerations
- Catalog views are metadata reads. They are cheap compared with scanning user data, but a database with tens of thousands of objects returns a large index-level result set, so filter by schema or table when you only need part of it.
sys.partitions.rowsis approximate. It is "the approximate number of rows in this partition". For an exact figure on one table,COUNT(*)against the table returns the real count, at the cost of reading it.- Avoid
SELECT *against catalog views. New columns may be added to the end of any system catalog view, soSELECT * FROM sys.<catalog_view_name>can break a production application. This script names every column it returns. - Metadata visibility is permission-scoped. The visibility of metadata in catalog views is limited to securables that a user either owns or has been granted some permission on. The same query can return different rows for different logins, and an empty result for a low-privilege account is not proof that the database is empty.
- The catalog has gaps by design. Catalog views do not contain information about replication, backup, database maintenance plan, or SQL Server Agent catalog data. Those need their own queries.
- Database scope. Each view describes objects within a database, so run the script in the context of each database you want to inventory.
Practical Tips
- To include views and functions alongside tables, start from
sys.objectsinstead ofsys.tablesand filter on thetypecolumn. UseUfor a user table andVfor a view. Joinsys.indexesthe same way; it covers tabular objects such as a table, view, or table-valued function. - Add
sys.columns, joined onobject_id, to report column counts. It has a row for each column of an object that has columns, such as views or tables, and column IDs might not be sequential. - Join
sys.index_columnsonobject_idandindex_idto list the key columns of each index. Itskey_ordinalcolumn gives the 1-based position in the key, andis_included_columnseparatesINCLUDEcolumns from key columns. - To find heaps, filter the result on
i.index_id = 0. - To find tables with no primary key, the usual check is
OBJECTPROPERTY(object_id, 'TableHasPrimaryKey') = 0. The companion post on finding tables without primary keys covers it. - Capture the result into a reporting table on a schedule and compare snapshots over time to track schema drift and table growth across releases.
Conclusion
sys.objects, sys.tables, and sys.indexes carry the core of SQL Server's object metadata, and the shared object_id makes a complete table-and-index inventory one set of joins. Summing sys.partitions per table keeps the row count honest on partitioned tables, and sticking to named columns protects the script from future column additions. Keep it as the first query to run when you need to know what a database actually contains.
References
- sys.objects (Transact-SQL) — Microsoft Learn — Base catalog view for schema-scoped objects, with type codes,
modify_datebehaviour, and the DDL trigger exclusion. - sys.tables (Transact-SQL) — Microsoft Learn — Derived view for user tables and the table-specific columns.
- sys.indexes (Transact-SQL) — Microsoft Learn — One row per index or heap, with
index_id,type_desc, andis_hypotheticalsemantics. - sys.partitions (Transact-SQL) — Microsoft Learn — Source of the approximate
rowscount per partition. - sys.schemas (Transact-SQL) — Microsoft Learn — One row per database schema, with
nameandschema_id. - sys.columns (Transact-SQL) — Microsoft Learn — Column-level catalog view mentioned in the tips.
- sys.sysindexes (Transact-SQL) — Microsoft Learn — Backward-compatibility view for the SQL Server 2000 system table, with the recommendation to use current views.
- sys.index_columns (Transact-SQL) — Microsoft Learn — Index key and included columns, with
key_ordinal. - Catalog views (Transact-SQL) — Microsoft Learn — Overview of catalog-view inheritance, the base and derived view model, and the
SELECT *warning. - Metadata visibility configuration — Microsoft Learn — How permissions limit which rows catalog views return.
Posts in this series
- sp_pkeys: SQL Server Primary Key Discovery Script
- SQL Server: Find Tables with Clustered Indexes for Rebuild
- SQL Server: Find Tables Without Primary Keys
- SQL Server Foreign Keys Without Indexes Script
- SQL Server List All Foreign Keys with Referenced Tables
- SQL Server Find Columns by Data Type Across the Database
- SQL Server Catalog Views: sys.tables, sys.indexes, sys.objects
- sysindexes vs sys.indexes: Legacy and Modern Catalog Views
- sp_pkeys and Primary Key Metadata in SQL Server
- SQL Server Database Schema and Data Dictionary Queries
- System-Versioned (Temporal) Tables in SQL Server