Classify Azure SQL Columns as Restricted, Confidential or Internal
The short answer
Put your rules in a table: each pairs a column-name or value pattern with a level, and the highest matching level wins. A T-SQL script applies them and records which rule decided. In a test on SQL Server 2022, not Azure SQL itself, 17 rules gave all 21 columns a level and three checks stopped the write when we planted faults.
What you will have at the end
One T-SQL file, classify_columns.sql. It holds 17 stated rules in a table (15 match a column’s name, 2 match the shape of its values), a view that gives every column the highest level any matching rule assigns, three built-in checks, and a step that writes the labels into the database’s own sensitivity metadata only when all three checks pass. It creates no database: you run it inside an empty test database you own. We ran it twice: once with three planted faults, once clean.
The test table has the 21 column names of the public FEC individual-contributions file layout. Its 1,000 rows are generated by the script, so no real person’s value appears anywhere. We ran it on SQL Server 2022 in a container, not on an Azure SQL Database. The sensitivity-classification statements and views it relies on are documented for both, and we link them below. We did not run the file on Azure SQL, and we did not check the rest of its T-SQL against Azure SQL’s documentation.
Step 1. Write the rules down as data
A rule is a row: an id, a level, a kind, a pattern and a reason. A NAME rule matches the column’s name. A VALUE rule matches the values in the column, and fires when at least a stated share of the sampled values match. Patterns use T-SQL LIKE, where _ means any single character, so a literal underscore is written [_] (Microsoft: LIKE (read 2026-10-20)). LIKE comparisons are affected by collation, so a case-sensitive database matches differently (same page). The levels live in their own table, and a rule’s level is a foreign key to it, so a misspelled level cannot be inserted.
-- 1. The levels, and the stated rules. A rule's level must be one of the three levels (foreign key).
CREATE TABLE classify.levels (level_name varchar(12) PRIMARY KEY, level_rank int NOT NULL UNIQUE);
INSERT INTO classify.levels VALUES ('restricted', 3), ('confidential', 2), ('internal', 1);
CREATE TABLE classify.rules (
rule_id char(3) PRIMARY KEY,
level_name varchar(12) NOT NULL REFERENCES classify.levels (level_name),
kind varchar(5) NOT NULL CHECK (kind IN ('NAME', 'VALUE')),
pattern nvarchar(200) NOT NULL, -- a T-SQL LIKE pattern; write a literal underscore as [_]
min_share decimal(5,4) NULL, -- VALUE rules: share of sampled values that must match
reason nvarchar(200) NOT NULL,
-- a VALUE rule without a share would never fire; a NAME rule takes none.
-- IS NOT NULL matters: a CHECK lets a NULL comparison (unknown) through.
CONSTRAINT rules_min_share CHECK ((kind = 'NAME' AND min_share IS NULL)
OR (kind = 'VALUE' AND min_share IS NOT NULL AND min_share > 0 AND min_share <= 1)));
INSERT INTO classify.rules VALUES
('R01', 'restricted', 'NAME', 'NAME', NULL, 'Names a person'),
('R02', 'restricted', 'VALUE', '[0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9][0-9][0-9]', 0.01, 'Values shaped like a US Social Security number'),
('R03', 'restricted', 'VALUE', '%_@_%.__%', 0.01, 'Values shaped like an email address'),
('R04', 'confidential', 'NAME', 'CITY', NULL, 'Location of a person'),
('R05', 'confidential', 'NAME', 'STATE', NULL, 'Location of a person'),
('R06', 'confidential', 'NAME', 'ZIP[_]CODE', NULL, 'Location of a person'),
('R07', 'confidential', 'NAME', 'EMPLOYER', NULL, 'Where a person works'),
('R08', 'confidential', 'NAME', 'OCCUPATION', NULL, 'What a person does'),
('R09', 'confidential', 'NAME', 'TRANSACTION[_]AMT', NULL, 'An amount tied to a person'),
('R10', 'confidential', 'NAME', 'MEMO[_]TEXT', NULL, 'Free text can hold anything'),
('R11', 'internal', 'NAME', '%[_]ID', NULL, 'System identifier'),
('R12', 'internal', 'NAME', '%[_]TP', NULL, 'Type code'),
('R13', 'internal', 'NAME', '%[_]NUM', NULL, 'Reference number'),
('R14', 'internal', 'NAME', '%[_]IND', NULL, 'Indicator flag'),
('R15', 'internal', 'NAME', '%[_]CD', NULL, 'Code'),
('R16', 'internal', 'NAME', 'TRANSACTION[_]PGI', NULL, 'Code'),
('R17', 'internal', 'NAME', 'TRANSACTION[_]DT', NULL, 'Date of the transaction');
GO
The same rules, as the file stores them:
| Rule | Level | Kind | Pattern | Min share | Reason |
|---|---|---|---|---|---|
| R01 | restricted | NAME | NAME | Names a person | |
| R02 | restricted | VALUE | [0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9][0-9][0-9] | 0.01 | Values shaped like a US Social Security number |
| R03 | restricted | VALUE | %_@_%.__% | 0.01 | Values shaped like an email address |
| R04 | confidential | NAME | CITY | Location of a person | |
| R05 | confidential | NAME | STATE | Location of a person | |
| R06 | confidential | NAME | ZIP[_]CODE | Location of a person | |
| R07 | confidential | NAME | EMPLOYER | Where a person works | |
| R08 | confidential | NAME | OCCUPATION | What a person does | |
| R09 | confidential | NAME | TRANSACTION[_]AMT | An amount tied to a person | |
| R10 | confidential | NAME | MEMO[_]TEXT | Free text can hold anything | |
| R11 | internal | NAME | %[_]ID | System identifier | |
| R12 | internal | NAME | %[_]TP | Type code | |
| R13 | internal | NAME | %[_]NUM | Reference number | |
| R14 | internal | NAME | %[_]IND | Indicator flag | |
| R15 | internal | NAME | %[_]CD | Code | |
| R16 | internal | NAME | TRANSACTION[_]PGI | Code | |
| R17 | internal | NAME | TRANSACTION[_]DT | Date of the transaction |
These levels and rules are ours, written for this demo. They are not a legal standard, and your policy will differ. The point is that they are rows anyone can read, argue with and change, and that every label can be traced to the rule that produced it.
Step 2. List the columns and scan their values
INFORMATION_SCHEMA.COLUMNS returns one row for each column the current user can access in the current database, and is documented for SQL Server and Azure SQL Database (Microsoft: COLUMNS (read 2026-10-20)). Run the file as a login that can see every column you mean to classify, or a hidden column is silently missing from the inventory. The view below limits it to base tables in the schema fec.
The procedure then takes each character column and each VALUE rule, reads up to 1,000 non-NULL values of that column in no stated order, and records how many match. Only the counts are stored, never a value.
-- 3. The columns to classify (base tables in schema fec) and what the value rules found.
CREATE VIEW classify.column_inventory AS
SELECT c.TABLE_SCHEMA, c.TABLE_NAME, c.COLUMN_NAME, c.ORDINAL_POSITION, c.DATA_TYPE
FROM INFORMATION_SCHEMA.COLUMNS c
JOIN INFORMATION_SCHEMA.TABLES t
ON t.TABLE_SCHEMA = c.TABLE_SCHEMA AND t.TABLE_NAME = c.TABLE_NAME AND t.TABLE_TYPE = 'BASE TABLE'
WHERE c.TABLE_SCHEMA = 'fec';
GO
CREATE TABLE classify.value_hits (
TABLE_NAME sysname, COLUMN_NAME sysname, rule_id char(3), sampled int, hits int);
GO
CREATE PROCEDURE classify.scan_values AS
BEGIN
SET NOCOUNT ON;
TRUNCATE TABLE classify.value_hits;
DECLARE @sch sysname, @tbl sysname, @col sysname, @rid char(3), @pat nvarchar(200),
@sql nvarchar(max), @n int, @h int;
DECLARE c CURSOR LOCAL FAST_FORWARD FOR
SELECT i.TABLE_SCHEMA, i.TABLE_NAME, i.COLUMN_NAME, r.rule_id, r.pattern
FROM classify.column_inventory i CROSS JOIN classify.rules r
WHERE r.kind = 'VALUE' AND i.DATA_TYPE IN ('char', 'varchar', 'nchar', 'nvarchar');
OPEN c;
FETCH NEXT FROM c INTO @sch, @tbl, @col, @rid, @pat;
WHILE @@FETCH_STATUS = 0
BEGIN
SET @sql = N'SELECT @n = COUNT(*), @h = COALESCE(SUM(CASE WHEN v LIKE @p THEN 1 ELSE 0 END), 0) '
+ N'FROM (SELECT TOP (1000) CAST(' + QUOTENAME(@col) + N' AS nvarchar(4000)) AS v FROM '
+ QUOTENAME(@sch) + N'.' + QUOTENAME(@tbl) + N' WHERE ' + QUOTENAME(@col)
+ N' IS NOT NULL ORDER BY (SELECT NULL)) s;';
EXEC sp_executesql @sql, N'@p nvarchar(200), @n int OUTPUT, @h int OUTPUT',
@p = @pat, @n = @n OUTPUT, @h = @h OUTPUT;
INSERT INTO classify.value_hits VALUES (@tbl, @col, @rid, @n, @h);
FETCH NEXT FROM c INTO @sch, @tbl, @col, @rid, @pat;
END;
CLOSE c; DEALLOCATE c;
END;
GO
Step 3. Pick the highest level, then check the rules themselves
The result view joins every column to every rule that matches it, by name or by values, and keeps the highest level. It also keeps the level the name rules alone would give and the level the value rules alone would give, so a disagreement is visible.
SQL: the result-- 4. The result: the highest level any matching rule gives wins.
CREATE VIEW classify.result AS
WITH hits AS (
SELECT i.TABLE_NAME, i.COLUMN_NAME, r.rule_id, l.level_rank, 'NAME' AS kind
FROM classify.column_inventory i
JOIN classify.rules r ON r.kind = 'NAME' AND i.COLUMN_NAME LIKE r.pattern
JOIN classify.levels l ON l.level_name = r.level_name
UNION ALL
SELECT h.TABLE_NAME, h.COLUMN_NAME, r.rule_id, l.level_rank, 'VALUE'
FROM classify.value_hits h
JOIN classify.rules r ON r.rule_id = h.rule_id
JOIN classify.levels l ON l.level_name = r.level_name
WHERE h.hits > 0 AND h.hits >= r.min_share * h.sampled
), per_column AS (
SELECT i.TABLE_SCHEMA, i.TABLE_NAME, i.COLUMN_NAME, i.ORDINAL_POSITION,
MAX(CASE WHEN h.kind = 'NAME' THEN h.level_rank END) AS name_rank,
MAX(CASE WHEN h.kind = 'VALUE' THEN h.level_rank END) AS value_rank,
MAX(h.level_rank) AS final_rank,
STRING_AGG(h.rule_id, ',') WITHIN GROUP (ORDER BY h.rule_id) AS rules
FROM classify.column_inventory i
LEFT JOIN hits h ON h.TABLE_NAME = i.TABLE_NAME AND h.COLUMN_NAME = i.COLUMN_NAME
GROUP BY i.TABLE_SCHEMA, i.TABLE_NAME, i.COLUMN_NAME, i.ORDINAL_POSITION)
SELECT p.TABLE_SCHEMA, p.TABLE_NAME, p.COLUMN_NAME, p.ORDINAL_POSITION,
ln.level_name AS name_level, lv.level_name AS value_level,
lf.level_name AS level, p.rules, p.name_rank, p.value_rank, p.final_rank
FROM per_column p
LEFT JOIN classify.levels ln ON ln.level_rank = p.name_rank
LEFT JOIN classify.levels lv ON lv.level_rank = p.value_rank
LEFT JOIN classify.levels lf ON lf.level_rank = p.final_rank;
GO
Three checks test the rules, not the data. Each returns a count of problems, and any problem stops the write in step 5.
- Every column gets a level. Fails when a column matches no rule. Our planted faults 2 and 3 trip it.
- Every name rule matches a column. Fails when a
NAMEpattern matches nothing, which is what a typo looks like. Planted fault 2 trips it. We do not apply this toVALUErules, because a rule for Social-Security-shaped values should match nothing in a clean table. - No value rule outranks the column name. Fails when the values say a higher level than the name did, such as an ID column holding Social-Security-shaped values. Planted fault 1 trips it.
-- 5. The built-in checks. Each returns the number of problems; any problem blocks the write.
CREATE VIEW classify.findings AS
SELECT 'every column gets a level' AS check_name, COLUMN_NAME AS item,
'no rule matches this column' AS detail
FROM classify.result WHERE final_rank IS NULL
UNION ALL
SELECT 'every name rule matches a column', r.rule_id,
'pattern ' + r.pattern + ' matches no column'
FROM classify.rules r
WHERE r.kind = 'NAME'
AND NOT EXISTS (SELECT 1 FROM classify.column_inventory i WHERE i.COLUMN_NAME LIKE r.pattern)
UNION ALL
SELECT 'no value rule outranks the column name', COLUMN_NAME,
'name rules say ' + COALESCE(name_level, 'nothing') + ', values say ' + value_level
FROM classify.result WHERE value_rank > COALESCE(name_rank, 0);
GO
CREATE VIEW classify.checks AS
SELECT c.check_name, COUNT(f.item) AS problems
FROM (VALUES ('every column gets a level'), ('every name rule matches a column'),
('no value rule outranks the column name')) c (check_name)
LEFT JOIN classify.findings f ON f.check_name = c.check_name
GROUP BY c.check_name;
GO
Step 4. Run it with planted faults, then clean
With PLANT=1 the file plants three faults before it classifies:
-- 2. TEST ONLY: plant three faults when PLANT=1.
IF $(PLANT) = 1
BEGIN
-- fault 1: an "internal" ID column that holds Social-Security-shaped values
UPDATE fec.contributions SET TRAN_ID = '000-00-' + RIGHT('0000' + CAST(SUB_ID AS varchar(4)), 4);
-- fault 2: a typo in a rule's pattern, so it matches no column
UPDATE classify.rules SET pattern = 'ZIPCODE' WHERE rule_id = 'R06';
-- fault 3: a new column that no rule covers
ALTER TABLE fec.contributions ADD BIRTH_DT date NULL;
END;
GO
The run, with sqlcmd (-b makes sqlcmd stop at the first error, so the statements after the failed check did not run):
sqlcmd -S <server> -d <test database> -U <user> -P <password> -b -W -s "|" -v PLANT=1 -i classify_columns.sql
Output from our run: the classification with the three faults in place
COLUMN_NAME|name_level|value_level|level|rules -----------|----------|-----------|-----|----- CMTE_ID|internal|NULL|internal|R11 AMNDT_IND|internal|NULL|internal|R14 RPT_TP|internal|NULL|internal|R12 TRANSACTION_PGI|internal|NULL|internal|R16 IMAGE_NUM|internal|NULL|internal|R13 TRANSACTION_TP|internal|NULL|internal|R12 ENTITY_TP|internal|NULL|internal|R12 NAME|restricted|NULL|restricted|R01 CITY|confidential|NULL|confidential|R04 STATE|confidential|NULL|confidential|R05 ZIP_CODE|NULL|NULL|NULL|NULL EMPLOYER|confidential|NULL|confidential|R07 OCCUPATION|confidential|NULL|confidential|R08 TRANSACTION_DT|internal|NULL|internal|R17 TRANSACTION_AMT|confidential|NULL|confidential|R09 OTHER_ID|internal|NULL|internal|R11 TRAN_ID|internal|restricted|restricted|R02,R11 FILE_NUM|internal|NULL|internal|R13 MEMO_CD|internal|NULL|internal|R15 MEMO_TEXT|confidential|NULL|confidential|R10 SUB_ID|internal|NULL|internal|R11 BIRTH_DT|NULL|NULL|NULL|NULL
ZIP_CODE and BIRTH_DT get no level. TRAN_ID is restricted because rule R02 found Social-Security-shaped values in a column whose name said internal. The checks and the findings:
check_name|problems ----------|-------- every column gets a level|2 every name rule matches a column|1 no value rule outranks the column name|1 check_name|item|detail ----------|----|------ every column gets a level|BIRTH_DT|no rule matches this column every column gets a level|ZIP_CODE|no rule matches this column every name rule matches a column|R06|pattern ZIPCODE matches no column no value rule outranks the column name|TRAN_ID|name rules say internal, values say restricted Warning: Null value is eliminated by an aggregate or other SET operation. Warning: Null value is eliminated by an aggregate or other SET operation. Msg 50001, Level 16, State 1, Server 5e56364b6284, Procedure classify.apply, Line 11 Checks failed: 3 of 3. No label written.
The two Warning lines are SQL Server’s notice that an aggregate skipped NULLs. They are harmless here. The error is the file’s own: classify.apply refuses to write while any check has a problem, and sqlcmd stopped there. The labeling queries after it did not run, so the database has no sensitivity labels.
Then the same file with -v PLANT=0, in a second, empty test database. We ran it directly after the planted run:
check_name|problems ----------|-------- every column gets a level|0 every name rule matches a column|0 no value rule outranks the column name|0 Warning: Null value is eliminated by an aggregate or other SET operation. check_name|item|detail ----------|----|------ Warning: Null value is eliminated by an aggregate or other SET operation. column_name|label|information_type -----------|-----|---------------- CMTE_ID|internal|rules R11 AMNDT_IND|internal|rules R14 RPT_TP|internal|rules R12 TRANSACTION_PGI|internal|rules R16 IMAGE_NUM|internal|rules R13 TRANSACTION_TP|internal|rules R12 ENTITY_TP|internal|rules R12 NAME|restricted|rules R01 CITY|confidential|rules R04 STATE|confidential|rules R05 ZIP_CODE|confidential|rules R06 EMPLOYER|confidential|rules R07 OCCUPATION|confidential|rules R08 TRANSACTION_DT|internal|rules R17 TRANSACTION_AMT|confidential|rules R09 OTHER_ID|internal|rules R11 TRAN_ID|internal|rules R11 FILE_NUM|internal|rules R13 MEMO_CD|internal|rules R15 MEMO_TEXT|confidential|rules R10 SUB_ID|internal|rules R11 label|columns_labeled -----|--------------- confidential|7 internal|13 restricted|1
All three checks have zero problems and the findings list is empty. 21 labels were written: 1 restricted, 7 confidential and 13 internal. Each label’s information type records the rule ids that produced it, so a reviewer can go from a label to its reason.
Step 5. Write the labels into the database
ADD SENSITIVITY CLASSIFICATION attaches a label and an information type to a column. It applies to SQL Server 2019 and later, Azure SQL Database, Azure SQL Managed Instance and Azure Synapse Analytics, needs the ALTER ANY SENSITIVITY CLASSIFICATION permission, and overwrites a column’s existing classification (Microsoft: ADD SENSITIVITY CLASSIFICATION (read 2026-10-20)). The labels are then listed by sys.sensitivity_classifications, which needs VIEW ANY SENSITIVITY CLASSIFICATION (Microsoft: sys.sensitivity_classifications (read 2026-10-20)).
The statement takes literal names, so the procedure builds one statement per column with QUOTENAME and runs it with sp_executesql. It refuses to run at all while a check has a problem.
-- 6. Write the labels into the database's own sensitivity metadata, only if every check passed.
CREATE PROCEDURE classify.apply AS
BEGIN
SET NOCOUNT ON;
IF EXISTS (SELECT 1 FROM classify.checks WHERE problems > 0)
BEGIN
DECLARE @msg nvarchar(200) = 'Checks failed: '
+ CAST((SELECT COUNT(*) FROM classify.checks WHERE problems > 0) AS varchar(3))
+ ' of ' + CAST((SELECT COUNT(*) FROM classify.checks) AS varchar(3)) + '. No label written.';
THROW 50001, @msg, 1;
END;
DECLARE @sql nvarchar(max);
DECLARE c CURSOR LOCAL FAST_FORWARD FOR
SELECT N'ADD SENSITIVITY CLASSIFICATION TO ' + QUOTENAME(TABLE_SCHEMA) + N'.' + QUOTENAME(TABLE_NAME)
+ N'.' + QUOTENAME(COLUMN_NAME) + N' WITH (LABEL = ''' + level + N''', INFORMATION_TYPE = ''rules '
+ rules + N''');'
FROM classify.result;
OPEN c;
FETCH NEXT FROM c INTO @sql;
WHILE @@FETCH_STATUS = 0
BEGIN
EXEC sp_executesql @sql;
FETCH NEXT FROM c INTO @sql;
END;
CLOSE c; DEALLOCATE c;
END;
GO
Microsoft notes that when you manage labels with T-SQL, nothing validates that a label exists in your organization’s information-protection policy (Microsoft: Data Discovery & Classification (read 2026-10-20)). Our three label names come from our own levels table, so keep the two in step by hand.
How this sits next to Azure SQL’s and Purview’s own classification
This is a description from Microsoft’s documentation, not a test. We did not run either built-in tool and make no claim about which finds more.
- Azure SQL Data Discovery & Classification. Built into Azure SQL Database and Managed Instance. Its engine scans a database for columns with potentially sensitive data and recommends classifications that you review and accept in the portal. You can customize the taxonomy, including custom information types configured with string patterns (Microsoft: Data Discovery & Classification (read 2026-10-20)). It stores its result in the same
sys.sensitivity_classificationsmetadata this file writes to. - Microsoft Purview Data Map. Classifies assets while it scans a source, with 200+ system classifications and custom rules based on a regular expression or a dictionary. It says sensitivity labels are different from classifications (Microsoft: Data classification in Purview (read 2026-10-20)).
- This file. The rules are rows in a table you own, in plain T-SQL, with the highest level winning and checks that test the rules. It has two value patterns and fifteen name patterns, and nothing else: no built-in catalog of information types.
Use the built-in tools for broad discovery. Use stated rules when you must show, column by column, which written rule produced a label.
Tested on
Tested on
- Platform
- SQL Server 2022 (16.0.4305.5), run in a Docker container. Not run on Azure SQL Database or Managed Instance.
- Edition
- Developer Edition (64-bit)
- Date
- 2026-10-20
- Dataset
- Generated records: 1,000 rows made by the script, no real values. The 21 column names come from the FEC individual-contributions file layout (read 2026-10-20). Run output is not a measurement of accuracy.
- Code
- classify_columns.sql
Use it on your own schema
The file creates no database. Run with -b, it refuses to run in master or another system database, and every schema, table, view and procedure is created with a plain CREATE, so if a name is already in use the run stops at that statement instead of replacing what is there. For the test run, use an empty test database you own, which you choose with sqlcmd’s -d. To point it at your own tables, connect with -d to the database that holds them, remove the CREATE SCHEMA fec statement and the steps marked 0 and 2 in the file (the generated rows and the planted faults), and change 'fec' in the inventory view to your schema. If that database already has a schema called classify, replace classify in the CREATE SCHEMA classify line and every classify. in the file with another schema name. Replace the rows in classify.rules with your own policy: the table refuses a VALUE rule without a share above 0 and up to 1, and a NAME rule with one. Once the planted-faults step (marked 2 in the file) is gone, the -v PLANT option is no longer needed. To run it again in the same database, drop the classify objects first. Because the write step (step 5 above) overwrites an existing classification on a column, look at sys.sensitivity_classifications first.
When this is not enough
- It measures nothing about accuracy. The table is generated and the rules were written to fit its column names, so the run shows the mechanism and the checks, not how many real columns the rules would label correctly or wrongly.
- Names and shapes only. It finds a column named for a person or holding values shaped like a Social Security number or email address. A column with an unhelpful name and unusual values is missed, and a column with a matching name can hold something else. Each pattern is a guess a data owner has to confirm.
- The value scan samples. It reads at most 1,000 non-NULL values per column in no stated order. Our table has 1,000 rows, so we did not test sampling on a larger one. The scan also reads real values, so it needs read access to them.
- The checks test the rules, not the labels. They catch an uncovered column, a rule that matches nothing and a value-versus-name conflict. They cannot tell whether a level is the right level.
- One schema, one database, run by hand. Nothing here is scheduled, versioned or reviewed, and nobody signs the result off.
- A label is a label. Writing it into
sys.sensitivity_classificationsrecords the classification. This file does not mask, encrypt or restrict anything.
Want this done for one of your data domains?
The Metadata Enrichment Engine does this as a fixed-scope project for one data domain. Sensitivity ratings follow stated rules, not AI, and a data owner approves each result.
It reads Snowflake or Azure SQL and runs in your own Azure subscription. If you would like to see how it would fit, tell us about the domain.