1Schema Creation and Definition
▶
A database schema is the formal blueprint that defines how data is organized within a relational database. Before a single row of data is ever inserted, the schema specifies every table that will exist, every column within those tables, the data type each column accepts, and the relationships that connect tables to one another. Think of the schema as an architectural plan: just as a building's blueprint dictates room sizes, wall placements, and connections between spaces, a database schema dictates the precise structure into which all future data must fit. Without a schema, a relational database would have no mechanism to enforce consistency, validate values, or express how one piece of information relates to another.
Schemas are the bridge between logical data modeling — the conceptual stage where analysts identify entities, attributes, and relationships — and physical implementation — the stage where those concepts become actual, queryable database objects. When a data modeler draws an entity-relationship diagram showing a Customer entity connected to an Order entity, the schema is the mechanism that translates that diagram into SQL structures a database engine can manage and enforce.
Modern relational database management systems (RDBMS) support hosting multiple schemas within a single database server or even within a single database. This capability allows organizations to separate distinct data domains — for example, keeping human resources data in an hr schema and financial records in a finance schema — without spinning up entirely separate database servers. Objects in different schemas can still reference one another when needed, but the logical separation makes permission management, maintenance, and conceptual clarity far easier.
The journey from concept to concrete database structure begins with two foundational SQL statements: CREATE DATABASE and CREATE TABLE. Understanding these statements, alongside the rules for choosing data types, declaring keys, and applying constraints, gives you everything you need to translate any logical model into a functioning, well-organized relational database.
The CREATE DATABASE statement is the entry point for establishing a new database environment within an RDBMS. When this command is executed, the database system allocates the necessary storage structures, initializes system catalog tables, and registers the new database as a manageable environment. From that point forward, all tables, indexes, views, and other objects created within that environment belong to it.
The syntax is straightforward:
CREATE DATABASE company_hr;
The name chosen for a database should be meaningful and reflect its domain or purpose. A name like company_hr immediately communicates that the database supports human resources functions for a company, whereas a name like db1 provides no such context and creates confusion as the system grows. Naming conventions vary by organization, but clarity and consistency should always guide the choice.
Some database systems — particularly PostgreSQL — make a distinction between a database and a schema as separate organizational levels. In PostgreSQL, a database is the outermost container, and within it you can create named schemas using CREATE SCHEMA to further subdivide objects:
CREATE SCHEMA payroll;
CREATE SCHEMA recruitment;
This two-level hierarchy (database → schema → tables) allows very fine-grained logical organization. In MySQL and MariaDB, CREATE DATABASE and CREATE SCHEMA are synonymous — both create what those systems call a database. Microsoft SQL Server similarly uses schemas as sub-namespaces within a database, with a default schema named dbo. Regardless of the specific system, the principle is the same: establish a named container before defining any objects within it.
Once a database exists, tables are defined using CREATE TABLE, which is the most important schema definition statement in SQL. Every table corresponds to a single entity identified during logical data modeling. If the logical model includes entities for Employee, Department, and Project, then the physical schema will include tables named employee, department, and project (or equivalent names following whatever naming convention is in use).
Each row in a table represents one instance of that entity — one specific employee, one specific department. Each column represents a single attribute of that entity. A CREATE TABLE statement lists every column by name, assigns it a data type, and optionally applies one or more constraints. The order in which columns are listed does not affect data integrity or correctness, but a logical, readable convention — such as placing the primary key first, followed by descriptive attributes, and finishing with timestamps or audit fields — makes the schema easier to read and maintain.
Here is a complete example:
CREATE TABLE department (
department_id INT NOT NULL,
department_name VARCHAR(100) NOT NULL,
location VARCHAR(100),
CONSTRAINT pk_department PRIMARY KEY (department_id)
);
This single statement creates the department table, defines three columns, enforces non-nullability on the key columns, and declares the primary key — all at once.
Choosing the right data type for each column is one of the most consequential decisions made during schema creation. Data types serve as the first line of defense against invalid data: a column declared as INT will reject any attempt to store alphabetical text, just as a column declared as DATE will reject values that cannot be interpreted as a valid calendar date.
Beyond validation, data types affect storage efficiency and query performance. Selecting a type that is unnecessarily large wastes disk space and memory, and can slow down operations that scan large numbers of rows. Selecting a type that is too small risks overflow (numeric values too large to store) or truncation (text silently cut off). The goal is to choose the smallest type that safely accommodates every realistic value the column will ever hold.
The following table summarizes the most commonly used data types and their appropriate use cases:
| Category | Type | Description | Typical Use Case |
|---|---|---|---|
| Integer | TINYINT |
1-byte integer (0–255 or −128–127) | Flags, small status codes |
| Integer | SMALLINT |
2-byte integer (−32,768–32,767) | Age, small counters |
| Integer | INT / INTEGER |
4-byte integer (approx. ±2.1 billion) | General-purpose IDs, quantities |
| Integer | BIGINT |
8-byte integer (approx. ±9.2 quintillion) | Large auto-increment IDs, financial transaction counts |
| Decimal | DECIMAL(p,s) / NUMERIC(p,s) |
Exact fixed-point number; p total digits, s after decimal | Monetary amounts, precise measurements |
| Floating point | FLOAT / REAL |
Approximate floating-point number | Scientific data where approximation is acceptable |
| Character | CHAR(n) |
Fixed-length string, always n characters (padded with spaces) | Country codes, fixed-format codes (e.g., US, ISO codes) |
| Character | VARCHAR(n) |
Variable-length string, up to n characters | Names, addresses, descriptions |
| Character | TEXT |
Large, unbounded string | Long-form text, notes, article bodies |
| Date/Time | DATE |
Calendar date (year, month, day) | Birth dates, hire dates |
| Date/Time | TIME |
Time of day | Shift start/end times |
| Date/Time | DATETIME / TIMESTAMP |
Combined date and time | Event logs, transaction timestamps |
| Boolean | BOOLEAN / BIT |
True/false value | Active flags, binary status indicators |
An important distinction exists between CHAR and VARCHAR. A column defined as CHAR(10) always occupies exactly 10 characters of storage, padding shorter values with trailing spaces. A column defined as VARCHAR(10) occupies only as much space as the actual stored value requires, plus a small overhead byte or two to record the length. For values that are always the same width — such as a two-letter country code — CHAR(2) is slightly more efficient. For values that vary widely in length — such as a person's full name — VARCHAR is the appropriate choice.
For monetary values, DECIMAL (or NUMERIC, which is synonymous in most systems) is strongly preferred over FLOAT or REAL. Floating-point types store approximations, which can introduce tiny rounding errors — invisible in casual use but potentially significant when summing thousands of financial transactions. DECIMAL(10, 2), for example, stores up to 10 total digits with exactly 2 after the decimal point, making it ideal for currency values up to 99,999,999.99.
Every well-designed table must have a primary key. A primary key is the column or combination of columns whose values uniquely identify every row in the table. The database engine automatically enforces two rules on primary key columns: every value must be unique (no two rows may share the same key value), and no value may be NULL. These guarantees make it possible to reliably retrieve any specific row, join tables together correctly, and establish foreign key references from other tables.
A simple primary key uses a single column. The most common pattern is a surrogate key — an integer that has no real-world meaning and exists solely to identify rows, often auto-generated by the database:
CREATE TABLE employee (
employee_id INT NOT NULL AUTO_INCREMENT,
first_name VARCHAR(50) NOT NULL,
last_name VARCHAR(50) NOT NULL,
hire_date DATE NOT NULL,
CONSTRAINT pk_employee PRIMARY KEY (employee_id)
);
Here, employee_id is assigned automatically by the database each time a new row is inserted. The AUTO_INCREMENT keyword (MySQL/MariaDB syntax; SERIAL or GENERATED ALWAYS AS IDENTITY in PostgreSQL, IDENTITY in SQL Server) ensures each new employee receives a unique, never-repeated identifier without any effort from the application.
A composite primary key uses two or more columns together to form a unique identifier. This pattern appears naturally in junction tables that resolve many-to-many relationships. For example, a table recording which employees are assigned to which projects might have no single column that uniquely identifies a row, but the combination of employee_id and project_id is always unique:
CREATE TABLE employee_project (
employee_id INT NOT NULL,
project_id INT NOT NULL,
assigned_date DATE NOT NULL,
CONSTRAINT pk_employee_project PRIMARY KEY (employee_id, project_id)
);
Because composite keys involve multiple columns, they are always declared as table-level constraints (listed after the column definitions) rather than inline with a single column definition.
Foreign keys are the SQL mechanism that implements the relationships identified during logical data modeling. A foreign key is a column (or set of columns) in one table whose values must match an existing primary key value in another table — or be NULL if the relationship is optional. The table containing the foreign key is called the child or referencing table; the table whose primary key is referenced is called the parent or referenced table.
Consider the relationship between employees and departments. Each employee belongs to exactly one department, but a department can have many employees — a classic one-to-many relationship. In the physical schema, this is implemented by placing a department_id foreign key column in the employee table:
CREATE TABLE employee (
employee_id INT NOT NULL AUTO_INCREMENT,
first_name VARCHAR(50) NOT NULL,
last_name VARCHAR(50) NOT NULL,
hire_date DATE NOT NULL,
department_id INT NOT NULL,
CONSTRAINT pk_employee PRIMARY KEY (employee_id),
CONSTRAINT fk_emp_dept FOREIGN KEY (department_id)
REFERENCES department (department_id)
ON DELETE RESTRICT
ON UPDATE CASCADE
);
The REFERENCES clause names the parent table and its primary key column. The database engine will now reject any INSERT or UPDATE on employee that tries to assign a department_id value that does not already exist in department.department_id. This enforcement is called referential integrity, and it prevents orphaned records — rows in the child table that refer to a parent that does not exist.
The ON DELETE and ON UPDATE clauses define what should happen to child rows when the referenced parent row is deleted or its key is updated. The most common options are:
| Action | Behavior | Typical Use |
|---|---|---|
RESTRICT / NO ACTION |
Prevents the parent from being deleted or updated if any child row references it | When child records must be explicitly removed first (most common default) |
CASCADE |
Automatically deletes or updates all child rows when the parent changes | When child records have no meaning without the parent (e.g., order line items with their order) |
SET NULL |
Sets the foreign key column in child rows to NULL when the parent is deleted or updated |
When the relationship is optional and a child can exist without a parent |
SET DEFAULT |
Sets the foreign key column to its declared default value | When a fallback parent (e.g., an "Unassigned" department) should be used |
For a many-to-many relationship — such as employees being assigned to multiple projects, and projects having multiple employees — the standard implementation is a junction table (also called an associative or bridge table) containing foreign keys that reference both parent tables, as shown earlier in the composite primary key example.
Beyond primary keys and foreign keys, SQL provides several additional column-level constraints that allow fine-grained control over what values a column may hold. These constraints are enforced by the database engine automatically, meaning application code does not need to duplicate these checks — the database itself guarantees compliance.
NOT NULL is the most fundamental column constraint. It declares that a column must always contain a value; it can never be left empty. When a row is inserted without providing a value for a NOT NULL column (and no default is defined), the database raises an error and rejects the insert. Columns that represent essential facts about an entity — a customer's last name, a product's price, an order's date — should almost always be NOT NULL. Allowing NULL in such columns creates ambiguity and complicates queries.
UNIQUE ensures that no two rows in the table may contain the same value in the constrained column (or combination of columns). It is similar to a primary key constraint in that it prevents duplicates, but it differs in two important ways: a table can have multiple UNIQUE constraints, and most database systems allow multiple NULL values in a UNIQUE column (since NULL is considered "unknown" and therefore not equal to any other value, including another NULL). A common use case is enforcing unique email addresses in a user table:
CREATE TABLE app_user (
user_id INT NOT NULL AUTO_INCREMENT,
email_address VARCHAR(255) NOT NULL,
username VARCHAR(50) NOT NULL,
CONSTRAINT pk_app_user PRIMARY KEY (user_id),
CONSTRAINT uq_user_email UNIQUE (email_address),
CONSTRAINT uq_user_username UNIQUE (username)
);
DEFAULT specifies a value that the database will automatically assign to a column when no explicit value is provided during an INSERT. Defaults are useful for columns that have a predictable value in most cases — such as a created_at timestamp that should default to the current date and time, or an is_active flag that should default to true for newly created records:
CREATE TABLE product (
product_id INT NOT NULL AUTO_INCREMENT,
product_name VARCHAR(150) NOT NULL,
unit_price DECIMAL(10, 2) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT pk_product PRIMARY KEY (product_id)
);
When a new product is inserted without specifying is_active or created_at, the database fills those columns with TRUE and the current timestamp, respectively.
CHECK constraints allow you to define a custom validation expression that every value in the column must satisfy. If an inserted or updated value violates the check expression, the database rejects the operation. This is the most flexible constraint type and can express a wide variety of business rules:
CREATE TABLE employee (
employee_id INT NOT NULL AUTO_INCREMENT,
first_name VARCHAR(50) NOT NULL,
last_name VARCHAR(50) NOT NULL,
salary DECIMAL(10, 2) NOT NULL,
hire_date DATE NOT NULL,
birth_date DATE,
CONSTRAINT pk_employee PRIMARY KEY (employee_id),
CONSTRAINT chk_salary CHECK (salary > 0),
CONSTRAINT chk_hire_after_birth CHECK (hire_date > birth_date)
);
The first check ensures no employee can be assigned a salary of zero or less. The second check enforces a logical temporal rule: an employee's hire date must come after their birth date. These rules are enforced at the database level, regardless of which application or user inserts the data.
Bringing all of these concepts together, a complete schema for a simple human resources system might look like this:
-- Step 1: Create the database
CREATE DATABASE company_hr;
-- Step 2: Define the parent table first (no foreign key dependencies)
CREATE TABLE department (
department_id INT NOT NULL AUTO_INCREMENT,
department_name VARCHAR(100) NOT NULL,
location VARCHAR(100),
CONSTRAINT pk_department PRIMARY KEY (department_id),
CONSTRAINT uq_dept_name UNIQUE (department_name)
);
-- Step 3: Define the child table (references department)
CREATE TABLE employee (
employee_id INT NOT NULL AUTO_INCREMENT,
first_name VARCHAR(50) NOT NULL,
last_name VARCHAR(50) NOT NULL,
email VARCHAR(255) NOT NULL,
hire_date DATE NOT NULL,
salary DECIMAL(10, 2) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
department_id INT NOT NULL,
CONSTRAINT pk_employee PRIMARY KEY (employee_id),
CONSTRAINT uq_employee_email UNIQUE (email),
CONSTRAINT fk_emp_dept FOREIGN KEY (department_id)
REFERENCES department (department_id)
ON DELETE RESTRICT
ON UPDATE CASCADE,
CONSTRAINT chk_salary CHECK (salary > 0)
);
-- Step 4: Define the project table
CREATE TABLE project (
project_id INT NOT NULL AUTO_INCREMENT,
project_name VARCHAR(150) NOT NULL,
start_date DATE NOT NULL,
end_date DATE,
CONSTRAINT pk_project PRIMARY KEY (project_id),
CONSTRAINT chk_project_dates CHECK (end_date IS NULL OR end_date >= start_date)
);
-- Step 5: Define the junction table for the many-to-many relationship
CREATE TABLE employee_project (
employee_id INT NOT NULL,
project_id INT NOT NULL,
assigned_date DATE NOT NULL,
CONSTRAINT pk_employee_project PRIMARY KEY (employee_id, project_id),
CONSTRAINT fk_ep_employee FOREIGN KEY (employee_id)
REFERENCES employee (employee_id)
ON DELETE CASCADE,
CONSTRAINT fk_ep_project FOREIGN KEY (project_id)
REFERENCES project (project_id)
ON DELETE CASCADE
);
Notice that tables are created in an order that respects their dependencies: department is created before employee (because employee has a foreign key referencing department), and both employee and project are created before employee_project (because the junction table references both). Attempting to create a table with a foreign key before the referenced table exists will produce an error in most database systems.
Every decision made during schema creation — the choice of data type, the presence of a NOT NULL constraint, the cascade behavior of a foreign key — becomes a standing rule enforced automatically by the database engine for the entire lifetime of that schema. A well-designed schema minimizes the need for application-level data cleaning, reduces the risk of inconsistent or invalid data accumulating over time, and makes the database self-documenting: anyone who reads the CREATE TABLE statements can immediately understand the structure, rules, and relationships that govern the data.