Schema lint is a mode that checks the diagram against forty-three rules and shows what it finds on the diagram itself: a colored strip in the margin marks the exact entity, and the exact field row, concerned, with a count beside any entity carrying more than one. Findings are grouped by what they cost you - whether they fail at runtime, leave a constraint unenforced, cost you something forever, or are a convention worth confirming - rather than graded on a severity scale. It runs entirely on your machine, against the diagram you already have open, with no connection required.

Each rule says what your database does, not what databases do in general. The same diagram on PostgreSQL and on SQL Server can get different findings, or the same finding in a different group, because the two engines accept different things - and every one of those differences was measured on PostgreSQL 18.3, CockroachDB 26.3, MySQL 8.4, SQL Server 2022 and SQLite 3.54.

What does schema lint check?

Forty-three rules ship today, each with a permanent id you will see in the Rules tab. Thirty apply to more than one database and are listed here in the four groups the drawer uses; the thirteen that belong to a single database are listed further down.

Fails at runtime - the database will reject this, in production, on a day nobody chose:

RuleWhat it catches
fk-type-mismatchA foreign key whose type does not match the column it references, where the database refuses the key: MySQL for a different base type, sign or character set; SQL Server for any difference in type, length or scale; PostgreSQL for a pair it has no comparison for, such as varchar(36) to uuid or numeric to integer; CockroachDB for anything outside one type family, date to timestamp included. A pair the database accepts, such as int to bigint, is reported under Costs instead: the key works, and some values fit one column but not the other.
default-contradicts-checkA default value the column’s own check constraint forbids - a default of 'pending' against a check allowing only 'open' and 'closed'. Every insert that relies on the default fails.
fk-cycle-all-not-nullA cycle of foreign keys in which every column is NOT NULL. On MySQL, SQL Server and SQLite each INSERT is checked on its own, so no row can ever go in. On PostgreSQL and CockroachDB one statement with a writable CTE fills the whole cycle, so there it is reported under Costs: an ORM inserting row by row still fails. A PostgreSQL key declared DEFERRABLE breaks the cycle, and a table referencing itself is a Convention finding, since every database accepts a first row that points at itself. Why, and the fixes.
set-null-on-not-null-fkON DELETE or ON UPDATE SET NULL on a column that is NOT NULL. Depending on the database, either the constraint is refused, or it is accepted and the delete or update fails when it runs. On PostgreSQL 15+, ON DELETE SET NULL (member_id) clears only the columns it lists, so only those are checked.
fk-target-not-uniqueA foreign key referencing columns that are neither the primary key nor covered by a unique constraint or unique index, so the database refuses the foreign key. A unique index with a WHERE condition, or with an expression among its keys such as lower(email), does not count: PostgreSQL refuses a key that points at one.
fk-columns-crossedA foreign key over two or more columns whose columns pair up crosswise, such as (member_id, company_id) referencing members (company_id, id). Columns pair by position, not by name, so the database creates the key and then refuses correct rows and accepts wrong ones. Reported only when another order would pair every column with its namesake, so a role name such as manager_id never raises it.
cascade-into-restrictA cascading delete that reaches a foreign key set to block deletes. Deleting the parent fails whenever the blocking table still has rows pointing at what the cascade removes. On MySQL a key set to SET DEFAULT blocks too, because MySQL treats it as RESTRICT. When the same delete also cascades to the blocking table by another path, MySQL, CockroachDB and SQL Server let it through, as does SQLite for NO ACTION; on PostgreSQL, and SQLite for RESTRICT, it depends on which cascade runs first, so the finding says it may block.
identifier-too-longA table, column, constraint, or index name past the database’s limit - 64 characters on MySQL, 128 on SQL Server, 255 on Snowflake - so the statement that creates it fails. PostgreSQL instead cuts names at 63 bytes, so the database’s name stops matching the diagram’s; that case is reported under Costs.
vector-index-dimensionsA pgvector HNSW or IVFFlat index on a column with no dimension, or with more than the index allows - 2,000 for vector, 4,000 for halfvec (PostgreSQL) - so creating the index fails.
index-unindexable-typeA primary key, unique key or index on a column type the database cannot put in an index key: json, xml or point in a PostgreSQL btree, or json in GIN (jsonb has both); TEXT or BLOB without a prefix length, or JSON, on MySQL; a MAX type, TEXT or XML on SQL Server.
index-key-too-longA key whose columns can hold more bytes than one index entry keeps. MySQL refuses past 3,072 bytes, so a varchar(1000) in utf8mb4 cannot be indexed whole. SQL Server allows 900 bytes for a primary key and 1,700 for any other index, creates it with a warning, and then fails the insert of a long enough value.

Not enforced - the schema looks like it guarantees something and does not:

RuleWhat it catches
no-primary-keyNo primary key, so no row can be identified or referenced. On Snowflake, which does not enforce a primary key on a standard table, the finding says what declaring one does there: it records the key for readers and modeling tools, and duplicates still have to be kept out by the load.
junction-no-uniquenessA link table with nothing enforcing uniqueness over its foreign key columns, so the same pairing inserts repeatedly.
junction-uniqueness-wrong-columnsUniqueness that exists but covers the wrong columns - unique on (member_id) alone, or a unique that drags in created_at and therefore constrains nothing. On Snowflake both link-table findings say the same as no-primary-key does there.
unique-includes-nullableA multi-column unique containing a nullable column. NULLs compare as distinct, so rows repeating the same values are accepted whenever that column is NULL. A unique index whose WHERE rules those columns out, such as WHERE external_id IS NOT NULL, is not reported: it leaves the NULL rows out on purpose.
enum-like-no-constraintA status, type, state, role, or kind column with nothing restricting its values - typos and values retired years ago are all still accepted.
fk-array-columnAn array column holding foreign keys (PostgreSQL). Nothing checks the referenced rows exist, deletes leave dangling entries, and membership lookups scan the array.
redundant-fk-pathA table that references another both directly and through a third table holding the same columns, with no constraint keeping the two paths in agreement, so a row can point at one parent directly and a different one through the middle table. This is the cross-tenant reference check: tasks.company_id points at one company while tasks.assignee_id points at a member of another, and row-level security never sees it, because foreign key checks bypass policies. The finding names the fix, a composite key such as (company_id, assignee_id) referencing members (company_id, id), and the UNIQUE (company_id, id) it needs when the table between lacks one; the finding goes away once that key is in place. Columns that are nullable on both tables are left alone: an optional reference such as a comment’s author and its task’s author is each row’s own fact.

Costs - it works, and it charges you for the rest of the schema’s life:

RuleWhat it catches
redundant-indexAn index whose columns are a leading prefix of a wider one. The wider index already serves the same queries, and every insert, update, and delete maintains both.
money-as-floatMoney or quantity in binary floating point. float and double cannot represent every decimal exactly, so totals drift as rows accumulate.
fk-no-covering-indexA foreign key with no index starting with its columns, so joins on it, and every delete or key update in the parent table, scan the child table. For a key that carries a tenant column, such as (company_id, task_id) referencing tasks (company_id, id), an index starting with task_id also counts: that column alone names the parent row.
cascade-chain-depthOne delete that cascades through three or more levels of tables, removing rows far from where it started.
timestamp-not-timestamptztimestamp instead of timestamptz on PostgreSQL - the same value reads as a different instant depending on the session time zone.

Convention - not wrong, worth confirming you meant it:

RuleWhat it catches
id-not-primary-keyAn id column exists but the primary key is something else, so anything identifying a row by id is not using the key the database enforces.
composite-pk-with-inbound-fksA composite primary key on a table other tables reference, so every one of them repeats the whole key.
nullable-booleanA nullable boolean - three states where two were intended, and every read has to decide what NULL means. On SQL Server that is a nullable BIT.
created-at-nullable-or-no-defaultcreated_at nullable, or without a default, so rows can be written with no creation time.
nullable-string-no-uniquenessA nullable string column with no uniqueness, so absence has two spellings: NULL and empty string. A blank form field submits the empty string, not NULL. A check that rules out the empty string (col <> '', a positive length, or a values list without it) clears the finding.
ambiguous-dual-relationTwo foreign keys to the same table told apart only by a number, such as user_id and user_id_2, where the name should say what role each one plays.
cross-context-composite-linkA link table with a composite primary key joining tables from different context views. Anything referring to its rows has to carry every foreign id; a surrogate id with a unique constraint keeps the other context from depending on all of them.

Each rule reports at most 200 findings, and says so when it stops rather than truncating silently.

Which rules belong to one database?

These thirteen check something only one engine does. A MySQL diagram never shows a SQL Server rule, and the reverse.

SQL Server

RuleGroupWhat it catches
sqlserver-multiple-cascade-pathsFails at runtimeA foreign key whose CASCADE, SET NULL or SET DEFAULT gives one delete or update two paths to the same table, or a loop - a direct and an indirect cascade into one table, two acting keys from one parent, or a cascading self-reference. A SET NULL or SET DEFAULT ends a delete’s path, since the row it changes is not deleted. SQL Server refuses the key (Msg 1785).
sqlserver-unique-index-one-nullFails at runtimeA unique index without a WHERE over a nullable column. SQL Server counts NULLs as equal there, so a second row with NULL is refused. Schemity already writes a unique constraint over a nullable column as an index filtered to the rows where it is set, so this only concerns an index you made.
sqlserver-datetime-roundingCostsA DATETIME column, which keeps time in steps of 1/300 second (10:00:00.001 is stored as 10:00:00.000), or a SMALLDATETIME, which rounds to the minute. DATETIME2 keeps 100 ns.

MySQL

RuleGroupWhat it catches
mysql-set-default-actionFails at runtimeON DELETE or ON UPDATE SET DEFAULT. MySQL accepts the clause, keeps it in SHOW CREATE TABLE, and then refuses the delete exactly as RESTRICT would (error 1451). The relationship dialog no longer offers it on MySQL.
mysql-enum-default-not-listedFails at runtimeAn ENUM or SET column whose default is not one of its values, compared as the default collation does, ignoring case. MySQL refuses the column (error 1067).
mysql-utf8mb3Fails at runtimeA string column in utf8mb3 (the old utf8), by its own character set or the table’s. It stores at most 3 bytes a character, so an emoji is refused (error 1366).

SQLite

RuleGroupWhat it catches
sqlite-numeric-affinity-stringCostsA column declared STRING, which SQLite gives numeric affinity by its type-name rules, so '0123' is stored as the integer 123. It arrives with a diagram converted from CockroachDB or Snowflake, where STRING is a text type. A STRICT table refuses the type instead.

CockroachDB

RuleGroupWhat it catches
crdb-sequential-primary-keyCostsA primary key that only grows - an identity, a serial, a sequence, or an insert time - so every insert writes to the same range. CockroachDB recommends a UUID key with gen_random_uuid().
crdb-sequential-indexCostsAn index that leads with a column that only grows, so its writes land on one range. A hash-sharded index (USING HASH) spreads them.
crdb-unsupported-typeFails at runtimeA PostgreSQL type CockroachDB does not have, such as MONEY, XML, POINT, CIDR or HALFVEC.
crdb-unsupported-indexFails at runtimeA hash, BRIN or IVFFlat index, or NULLS NOT DISTINCT. CockroachDB builds btree, GIN and HNSW.
crdb-deferrable-fkFails at runtimeA deferrable foreign key. CockroachDB checks every key at once and refuses the clause.
crdb-collation-not-localeFails at runtimeA collation such as C, POSIX or en_US.utf8. CockroachDB takes Unicode locale names only, such as en-US or und-u-ks-level2.

How do I open schema lint?

The Lint button sits in the control bar’s Review group, beside Impact; every toolbar tooltip names the group its button belongs to. Clicking it opens the lint drawer on the right, titled with the total finding count, with two tabs:

  • Findings - every finding, in collapsible groups per category, each group headed with its own count.
  • Rules - one row per rule that applies to your database, with a switch.

The drawer does not dim or mask the canvas, so you read the diagram and the list side by side rather than one at a time.

What does the count badge on the Lint button mean?

The badge is live whether or not the drawer is open, so a new problem announces itself instead of waiting to be looked for. It shows the total number of findings (9+ past nine) and takes its color from the most serious category present - so a diagram carrying only naming conventions stays grey and quiet, while one that will fail at runtime turns red.

Where do findings appear on the diagram?

In the margin, never on top of the entity. Lint draws a thin vertical strip a few pixels to the left of each flagged entity:

  • A finding about the entity as a whole spans the entity’s full height.
  • A finding about one field marks that field’s row exactly, so orders.total being a float points at total, not at orders.
  • An entity carrying more than one finding gets a count in the margin beside the strip.

The strip is colored by category, and each category also has its own fill - solid, hollow, or dotted - so the marking survives greyscale printing and reads the same for a color-blind reviewer. Lint markings are included in SVG export, so a review copy of the diagram carries its findings with it.

To go the other way, from the list to the diagram, click Show on canvas on any finding. Schemity selects the entity and pans it into the part of the canvas the drawer is not covering.

What do the four groups mean, and why not severity levels?

Because a severity scale asks you to accept somebody else’s ranking of your schema. Grouping by consequence tells you what happens instead, and lets you decide:

  • Fails at runtime - the database rejects it.
  • Not enforced - the constraint you think you have is not one.
  • Costs - correct, but you pay for it on every write or every query, forever.
  • Convention - a choice worth confirming.

The same principle governs the wording: findings state facts, not verdicts. A nullable boolean is reported as three states where two were intended, not as a mistake - so a deliberate choice reads as a choice, and you can move on.

How does lint treat junction tables?

It knows the difference between a link table that is correct and one that is not. Schemity treats an entity as a junction table when it holds two or more foreign keys over distinct columns and carries nothing else of substance - an id, created_at, updated_at, or deleted_at does not count against it.

Both correct forms are accepted, and neither is flagged:

  • members_roles with a composite primary key over (member_id, role_id).
  • member_roles with a surrogate id plus a unique constraint over (member_id, role_id).

Uniqueness is read from wherever it actually comes from - a unique constraint, a unique index, a single-field unique flag, or the primary key itself - the same sources behind the U marker on the canvas. See Check Constraints & Composite Unique.

That leaves the two ways a link table goes wrong: nothing covers the foreign key pair at all (junction-no-uniqueness), or something covers the wrong columns (junction-uniqueness-wrong-columns). The third, unique-includes-nullable, is the one that looks correct in a schema dump and enforces nothing.

How does lint treat virtual relations?

A virtual relation is a foreign key on the diagram that the database does not enforce, so lint counts it where the rule is about shape and ignores it where the rule is about enforcement. Junction table detection and the composite primary key rule count it, which is what lets junction tables be recognized on a schema where every relation is virtual. The not-null cycle rule excludes it, because a cycle only blocks every insert if the database checks the keys in it. The type mismatch rule still fires, since the two columns still disagree about which values fit; it only drops the part about the database rejecting the constraint.

How do I ignore a finding or turn off a rule?

Both, per diagram:

  • Ignore on any finding drops that single finding.
  • The Rules tab switches a whole rule off for this diagram.

Both are saved with the diagram in its JSON file, so they travel with it - a schema carries the conventions its authors agreed on instead of every person on the team re-dismissing the same notes, and a change to them shows up in a pull request like any other diagram change.

Ignores are keyed by name, not by internal id. Renaming an entity or a field therefore brings its ignored findings back: the thing you decided about is not quite the thing you have now, so the decision is worth making again.

Which rules run on which database?

Rules that do not apply to your engine are hidden from the Rules tab rather than shown switched off - a PostgreSQL-only rule is not part of a MySQL diagram’s vocabulary at all. Today:

  • The twelve rules above run on their own database only.
  • timestamp-not-timestamptz and fk-array-column run on PostgreSQL and CockroachDB, and vector-index-dimensions on PostgreSQL only.
  • index-unindexable-type runs on PostgreSQL, MySQL and SQL Server, and index-key-too-long on MySQL and SQL Server, the engines measured to refuse those keys.
  • unique-includes-nullable skips SQL Server, where a unique constraint treats NULLs as equal and so does hold, and Snowflake, which does not enforce unique constraints.
  • fk-no-covering-index skips MySQL, where InnoDB creates the index itself when the key is added, and Snowflake, which has no indexes.
  • fk-target-not-unique skips MySQL, where whether a non-unique target is accepted depends on the version and settings, and Snowflake, which never enforces a foreign key.
  • identifier-too-long skips SQLite, which has no identifier length limit, and CockroachDB, which kept all 100 characters of a 100-character name.
  • fk-cycle-all-not-null, set-null-on-not-null-fk, fk-columns-crossed, cascade-into-restrict and cascade-chain-depth skip Snowflake, which records foreign keys but never enforces them or runs their actions.

The other sixteen run on every database Schemity connects to, with wording that follows the engine where it matters: fk-type-mismatch and fk-cycle-all-not-null change group by database, as described above, and on Snowflake the primary key and link-table findings say that a declared key there documents the key rather than enforcing it.

Where is lint not available?

Schema lint is a licensed desktop feature, included in the 2-week trial and in a license. Beyond that, it is not offered in three places:

  • Context views - a context view is a read-only perspective on the main diagram, so the schema is linted where it is edited.
  • Read-only diagrams opened without their connection.
  • Database views and materialized views are not linted, since you do not control their shape from here. They are still read when checking foreign key types against them.

Does lint fix anything by itself?

No. Lint reports, you decide, and the change you make goes through the normal path: edit the ERD, review the generated migration SQL diff, apply it. Nothing is rewritten behind your back.

Next

Organize a growing schema with Context Views.