Attach your own metadata to tables, columns, and indexes — survives re-extraction, drives behavior at deployment, and reaches every expression field across SQL Server, PostgreSQL, and MySQL.
By the SchemaSmith Team · Last reviewed
Custom properties are how you attach your own metadata to the things in a schema package.
Every custom value lives inside a single property on its containing object called
Extensions. It's an open JSON bag: objects, arrays, strings, numbers,
booleans — anything the schema accepts. The core properties stay exactly where
they've always been; your data rides in the Extensions object alongside
them.
{
"Schema": "[dbo]",
"Name": "[Orders]",
"Columns": [],
"Extensions": {
"Environment": "Production",
"DataClassification": "PII",
"OwningTeam": "Identity"
}
}
{
"Name": "[Amount]",
"DataType": "DECIMAL(18,2)",
"Nullable": false,
"Extensions": {
"DataClassification": "Financial",
"MaskInNonProd": "true"
}
}
Nested objects and arrays work naturally:
{
"Name": "[Orders]",
"Extensions": {
"Retention": {
"Policy": "7years",
"Tier": "Hot"
},
"ResponsibleTeams": ["Billing", "Compliance"]
}
}
That's all there is to storage. One property name, one JSON bag, anywhere you want to attach metadata.
In earlier versions of SchemaSmith, custom properties were sprinkled directly into
the object alongside the standard fields. The current design consolidates them into
Extensions for two concrete reasons:
Environment, and you happened to be using
Environment as a custom property, the flat form would silently
overwrite your value. Under Extensions, your data is partitioned
from the engine's data forever.
.json-schemas/*.schema validation files on the fly from the live
C# type definitions. Every time those files are regenerated, they reflect
exactly the current standard properties. Your Extensions content
is never touched, never clobbered, and never silently validated against
properties it doesn't belong to.
Extensions is available on every table component type where it makes
sense. The specific set varies by platform because the underlying schema elements
vary.
| Object | Lives in |
|---|---|
Table |
Top-level table definition file |
Column |
Columns array |
Index |
Indexes array |
ForeignKey |
ForeignKeys array |
CheckConstraint |
CheckConstraints array |
Each platform contributes a small set of additional component types that also
carry Extensions.
| Object | Lives in |
|---|---|
XmlIndex |
XmlIndexes array |
Statistic |
Statistics array |
FullTextIndex |
FullTextIndex object |
IndexedView |
Top-level indexed view definition file |
| IndexedView Index | Indexes array inside an indexed view definition |
| Object | Lives in |
|---|---|
MaterializedView |
Top-level materialized view definition file |
| MaterializedView Index | Indexes array inside a materialized view definition |
ExcludeConstraint |
ExcludeConstraints array |
Statistic |
Statistics array |
| Object | Lives in |
|---|---|
FullTextIndex |
FullTextIndexes array |
Custom properties at any level are completely independent — a
DataClassification on the table and a DataClassification
on a column are two different values.
Product.json and Template.json do not support
Extensions. Custom properties belong to schema components, not to the
product or template configuration.
Here's where custom properties stop being passive metadata and start driving
behavior. When SchemaQuench processes a table, it walks Extensions
on every component and produces {{TokenName}} substitutions you can
reference in any expression field.
Extensions are available inside the
table's own ShouldApplyExpression using bare names
({{Environment}}).
Extensions are also available in
every child component's expression fields using the
Table. prefix
({{Table.Environment}}).
Extensions are available in that
component's own expression fields using bare names
({{MaskInNonProd}}).
Table.-prefixed tokens are merged.
If a bare name collides with a table token, the component wins.
string, number,
bool) become direct token values.
Extensions.Retention.Policy becomes
{{Retention.Policy}} at table scope or
{{Table.Retention.Policy}} from child components.
["Billing", "Compliance"] becomes Billing,Compliance.
Custom property tokens resolve anywhere script tokens resolve —
ShouldApplyExpression, Default,
CheckExpression, script body text, and so on. See
Script Tokens
for the exhaustive list.
{
"Schema": "[dbo]",
"Name": "[Orders]",
"Extensions": {
"Environment": "Production"
},
"Indexes": [
{
"Name": "[IX_Orders_CreatedDate]",
"IndexColumns": "[CreatedDate]",
"ShouldApplyExpression":
"SELECT CASE WHEN '{{Table.Environment}}' = 'Production' THEN 1 ELSE 0 END"
}
]
}
At quench time, the index applies only on the database whose deployment is flagged Production. No per-environment file copies. No branching. One declaration, one behavior, switched by a sidecar value your team controls.
{
"Schema": "[dbo]",
"Name": "[Documents]",
"Extensions": {
"Retention": {
"ArchiveDays": "90"
}
},
"Columns": [
{
"Name": "[ArchiveAfterDays]",
"DataType": "INT",
"Nullable": false,
"Default": "{{Table.Retention.ArchiveDays}}"
}
]
}
{
"Name": "[SSN]",
"DataType": "VARCHAR(11)",
"Nullable": true,
"Extensions": {
"PII": "true"
},
"ShouldApplyExpression":
"SELECT CASE WHEN '{{PII}}' = 'true' AND '{{Table.Environment}}' = 'NonProd' THEN 0 ELSE 1 END"
}
Bare names pull from the column's own Extensions;
Table.-prefixed names climb to the parent table.
SchemaTongs is a read-first tool. When it writes a table file back to a package
that already contains a table file for the same table, it preserves whatever
Extensions you had on the previous file. Your custom metadata
survives the round-trip.
Matching is done by the component's Name property (with
brackets/quotes stripped and case-insensitive comparison). Columns and the table
itself also fall back to OldName, so renamed components keep their
custom metadata as long as OldName is set correctly before the
refresh.
| Component | Matched by | Applies to |
|---|---|---|
Table |
root object | All platforms |
Column |
Name, then OldName |
All platforms |
Index |
Name |
All platforms |
ForeignKey |
Name |
All platforms |
CheckConstraint |
Name |
All platforms |
XmlIndex |
Name |
SQL Server |
FullTextIndex |
Name |
SQL Server, MySQL |
ExcludeConstraint |
Name |
PostgreSQL |
Statistic |
Name |
PostgreSQL |
MaterializedView |
root object | PostgreSQL |
IndexedView |
root object | SQL Server |
If you drop a component from the database between extractions, its
Extensions disappears with it — there's nothing to match against
in the new file.
SchemaSmith generates validation schemas (.json-schemas/*.schema) for
Product.json, Template.json, table JSON, materialized
view JSON, and indexed view JSON on the fly from the current C#
type definitions. Every time SchemaTongs extracts a package, those files are
written fresh based on the current engine.
Extensions is an open JToken — the generated schema
intentionally imposes no structure on it. That keeps the engine out of the
business of validating your data.
If you want editor validation for your Extensions shape,
you can hand-edit the relevant .schema file and add a JSON Schema
fragment under the Extensions property. When SchemaTongs regenerates
that schema file, it will preserve your custom Extensions
definition and merge it back into the newly generated schema. Your
validation rules outlive the regeneration cycle.
Tightening Extensions on tables.schema to require a
DataClassification value:
{
"properties": {
"Extensions": {
"type": "object",
"required": ["DataClassification"],
"properties": {
"DataClassification": {
"type": "string",
"enum": ["Public", "Internal", "Confidential", "PII", "Financial"]
},
"OwningTeam": { "type": "string" }
}
}
}
}
Drop that into .json-schemas/tables.schema under
properties.Extensions (merging with whatever the schema already
contains), and your editor enforces it for every table file in the package.
SchemaTongs will carry your fragment forward on the next extraction; it will not
re-validate the Extensions content itself.
SchemaSmith treats Extensions as a data-only feature — you edit
the JSON directly, you hand-author the optional Extensions schema
fragment, and you consume the values through script tokens. That's intentional:
it keeps the tooling focused on what every team needs. Schema authorship, not form
design.
Re-extraction preserves Extensions on every supported component as
described above. Anything you add manually survives subsequent schema refreshes
so long as the component name is unchanged (or OldName is set for
renames).
Reads Extensions at quench time and resolves the contents as script
tokens in expression fields. Extensions has no direct effect on DDL
generation; its influence is entirely through the token substitution mechanism.
The full table metadata — including Extensions — is
available at runtime through the {{TableSchema}} automatic token if
you need to emit it into a migration script or audit row.
Table files are round-tripped transparently; Extensions is neither
inspected nor modified.
Because everything lives inside the Extensions bag, there are no
reserved names to worry about — you cannot collide with a standard property.
That said, keep your names stable and descriptive. The names become script tokens
across your entire deployment, and renaming a property is a search-and-replace
across every expression field that uses it.
A few practices that hold up well:
DataClassification and dataClassification and
data_classification will all work, but consistency keeps the
tokens predictable.
Extensions.Retention.Policy reads better
across a big schema than Extensions.RetentionPolicy, and it gives
you a natural place to add related fields later.
Company. or Compliance. prefix prevents accidental
reuse across unrelated domains.