DDL — CREATE TABLE, ALTER TABLE and Constraints

DDL (Data Definition Language) defines the database structure — tables, columns, constraints, and relationships. Well-designed DDL includes proper constraints that enforce data integrity at the database level (not just the application level). A FOREIGN KEY constraint prevents orphaned records; a CHECK constraint prevents invalid enum values; a UNIQUE constraint prevents duplicate slugs. These constraints protect the database even when code has bugs or when data is modified directly through SSMS.

CREATE TABLE with Constraints

-- ── Users table (simplified — EF Core Identity manages the full schema) ────
CREATE TABLE dbo.Users (
    Id           NVARCHAR(450)  NOT NULL,
    Email        NVARCHAR(256)  NOT NULL,
    DisplayName  NVARCHAR(100)  NOT NULL,
    AvatarUrl    NVARCHAR(500)  NULL,
    IsActive     BIT            NOT NULL DEFAULT 1,
    CreatedAt    DATETIME2(7)   NOT NULL DEFAULT SYSUTCDATETIME(),
    CONSTRAINT PK_Users PRIMARY KEY CLUSTERED (Id),
    CONSTRAINT UQ_Users_Email UNIQUE (Email)
);
GO

-- ── Categories table ─────────────────────────────────────────────────────
CREATE TABLE dbo.Categories (
    Id          INT          NOT NULL IDENTITY(1,1),
    Name        NVARCHAR(100) NOT NULL,
    Slug        VARCHAR(100)  NOT NULL,
    Description NVARCHAR(500) NULL,
    CONSTRAINT PK_Categories PRIMARY KEY CLUSTERED (Id),
    CONSTRAINT UQ_Categories_Slug UNIQUE (Slug)
);
GO

-- ── Posts table ──────────────────────────────────────────────────────────
CREATE TABLE dbo.Posts (
    Id           INT            NOT NULL IDENTITY(1,1),
    AuthorId     NVARCHAR(450)  NOT NULL,
    Title        NVARCHAR(200)  NOT NULL,
    Slug         VARCHAR(200)   NOT NULL,
    Body         NVARCHAR(MAX)  NOT NULL,
    Excerpt      NVARCHAR(500)  NULL,
    CoverImageUrl NVARCHAR(500) NULL,
    Status       VARCHAR(20)    NOT NULL DEFAULT 'draft',
    ViewCount    INT            NOT NULL DEFAULT 0,
    IsPublished  BIT            NOT NULL DEFAULT 0,
    IsFeatured   BIT            NOT NULL DEFAULT 0,
    PublishedAt  DATETIME2(7)   NULL,
    CreatedAt    DATETIME2(7)   NOT NULL DEFAULT SYSUTCDATETIME(),
    UpdatedAt    DATETIME2(7)   NOT NULL DEFAULT SYSUTCDATETIME(),
    CONSTRAINT PK_Posts    PRIMARY KEY CLUSTERED (Id),
    CONSTRAINT UQ_Posts_Slug UNIQUE (Slug),
    CONSTRAINT FK_Posts_Users FOREIGN KEY (AuthorId)
        REFERENCES dbo.Users (Id)
        ON DELETE NO ACTION   -- do not delete posts when user is deleted
        ON UPDATE NO ACTION,
    CONSTRAINT CHK_Posts_Status CHECK (
        Status IN ('draft', 'review', 'published', 'archived')
    ),
    CONSTRAINT CHK_Posts_ViewCount CHECK (ViewCount >= 0)
);
GO

-- ── Junction table: PostTags ──────────────────────────────────────────────
CREATE TABLE dbo.Tags (
    Id    INT          NOT NULL IDENTITY(1,1),
    Name  NVARCHAR(50) NOT NULL,
    Slug  VARCHAR(50)  NOT NULL,
    CONSTRAINT PK_Tags      PRIMARY KEY CLUSTERED (Id),
    CONSTRAINT UQ_Tags_Slug UNIQUE (Slug)
);
GO

CREATE TABLE dbo.PostTags (
    PostId INT NOT NULL,
    TagId  INT NOT NULL,
    CONSTRAINT PK_PostTags PRIMARY KEY CLUSTERED (PostId, TagId),  -- composite PK
    CONSTRAINT FK_PostTags_Posts FOREIGN KEY (PostId)
        REFERENCES dbo.Posts (Id) ON DELETE CASCADE,  -- delete tags when post deleted
    CONSTRAINT FK_PostTags_Tags FOREIGN KEY (TagId)
        REFERENCES dbo.Tags  (Id) ON DELETE CASCADE
);
GO

-- ── ALTER TABLE examples ──────────────────────────────────────────────────
-- Add a column:
ALTER TABLE dbo.Posts
    ADD WordCount INT NULL;
GO

-- Add a constraint:
ALTER TABLE dbo.Posts
    ADD CONSTRAINT CHK_Posts_WordCount CHECK (WordCount IS NULL OR WordCount >= 0);
GO

-- Drop a column (must drop constraints first):
ALTER TABLE dbo.Posts DROP CONSTRAINT CHK_Posts_WordCount;
ALTER TABLE dbo.Posts DROP COLUMN WordCount;
GO
Note: ON DELETE CASCADE on a foreign key means that deleting a parent row automatically deletes all child rows. Use it carefully — it is appropriate for PostTags (deleting a post should remove its tag associations) but wrong for Posts → Users (deleting a user should not silently delete all their posts). The default (NO ACTION) raises an error if you try to delete a user with existing posts — forcing an explicit decision about what to do with their content before deletion.
Tip: Name all constraints explicitly using a convention: PK_TableName for primary keys, UQ_TableName_ColumnName for unique constraints, FK_TableName_ReferencedTable for foreign keys, CHK_TableName_ColumnName for check constraints, IX_TableName_ColumnName for indexes. Named constraints can be referenced in ALTER TABLE DROP CONSTRAINT statements and appear clearly in error messages. Unnamed constraints get system-generated names like PK__Posts__3214EC07... that are impossible to reference reliably.
Warning: IDENTITY(1,1) generates auto-incrementing values but does NOT guarantee no gaps. If a transaction inserts a row, gets an identity value, then rolls back — the identity value is consumed and not reused. Gaps in identity values are normal and expected. Never design application logic that assumes identity values are consecutive or that no gaps exist. If you need gapless sequential numbers (rare requirement), implement them with a separate counter table and a transaction, not with IDENTITY.

Common Mistakes

Mistake 1 — Unnamed constraints (system-generated names are unmaintainable)

❌ Wrong — PRIMARY KEY (Id) without a name; error messages show cryptic auto-generated names.

✅ Correct — always name constraints: CONSTRAINT PK_Posts PRIMARY KEY CLUSTERED (Id).

Mistake 2 — ON DELETE CASCADE on critical parent-child relationships (silent mass deletion)

❌ Wrong — FK_Posts_Users ON DELETE CASCADE; deleting a user silently deletes all their posts.

✅ Correct — use ON DELETE NO ACTION for important relationships; handle orphan cleanup explicitly in application logic.

🧠 Test Yourself

A Posts table has an IDENTITY column starting at 1. Rows are inserted with IDs 1, 2, 3. Row 3 is deleted. A new row is inserted. What is its ID?