LearnAI ToolsCareerPractice BuildsPlayContact
SQL ServerBeginner~1.5 hours

Inventory Management Database

Design and query a multi-table inventory system with suppliers, products, and stock levels.

Table DesignJoinsConstraints

Overview

A warehouse doesn't just track "how many of this product do we have" — it tracks who supplies it, what the product actually is, and how much of it sits in stock right now, and those are three different kinds of facts that belong in three different tables. Cramming a supplier's name and address directly onto every product row would mean repeating that same supplier information on every single product they supply, and updating an address would require finding and fixing every one of those rows. This project builds the normalized alternative: a `suppliers` table, a `products` table that references a supplier, and a `stock_levels` table that tracks quantity separately from either.

By the end of this tutorial you will have a three-table T-SQL schema — `suppliers`, `products`, and `stock_levels` — wired together with `FOREIGN KEY` constraints and `IDENTITY(1,1)` surrogate keys, plus `CHECK` constraints that make it structurally impossible for a stock quantity to go negative. You will also write the join queries that turn three tables of raw IDs into a readable inventory report.

What You'll Build
  • A `suppliers` table with an `IDENTITY(1,1)` primary key and a unique contact email.
  • A `products` table with a foreign key to `suppliers` and a `CHECK`-constrained unit price.
  • A `stock_levels` table tracking on-hand quantity per product, with a `CHECK (quantity_on_hand >= 0)`.
  • Foreign keys enforcing that every product has a real supplier and every stock row has a real product.
  • Three-way `JOIN` queries connecting suppliers, products, and current stock.
  • A low-stock report using `TOP N` to surface the products that need reordering soonest.

Prerequisites

  • CREATE TABLE basics in T-SQL — columns, data types (`INT`, `NVARCHAR`, `DECIMAL`, `DATE`), and `NOT NULL`.
  • Primary keys and foreign keys — what `PRIMARY KEY` and `FOREIGN KEY ... REFERENCES` enforce.
  • Basic SELECT — filtering with `WHERE`, sorting with `ORDER BY`, and limiting rows with `TOP N`.
  • The INSERT statement — adding rows with `INSERT INTO table (columns) VALUES (...)`.
  • A first look at JOIN — the idea that two tables can be combined on a matching column.

Project Structure

`suppliers` is the independent entity at the top — it doesn't need to know anything about products. `products` depends on `suppliers` through a foreign key. `stock_levels` depends on `products`, and is kept as its own table (rather than a column on `products`) because in a larger system stock is often tracked per warehouse location, so separating it now leaves room to grow without redesigning the schema later.

TablePurposeKey Columns
suppliersOne row per supplier companysupplier_id (PK), contact_email (UNIQUE)
productsOne row per product in the catalogproduct_id (PK), supplier_id (FK), sku (UNIQUE), unit_price
stock_levelsOne row per product's current stockstock_id (PK), product_id (FK), quantity_on_hand, reorder_threshold

Step 1: Create the Suppliers Table

`supplier_id` uses `IDENTITY(1,1)` — SQL Server's auto-numbering column property, starting at 1 and incrementing by 1 with every insert. It is a surrogate key: an ID with no real-world meaning, chosen purely because a company name can change (or two suppliers can share a name) and would make a poor primary key. `contact_email` gets its own `UNIQUE` constraint on top of that, so no two supplier records can be created with the same contact address by mistake.

CREATE TABLE suppliers (
supplier_id INT IDENTITY(1,1) PRIMARY KEY, -- Surrogate key: auto-numbered, no real-world meaning, safe to reference elsewhere
supplier_name NVARCHAR(100) NOT NULL, -- NVARCHAR stores Unicode text, safe for supplier names in any language
contact_email NVARCHAR(100) NOT NULL UNIQUE, -- UNIQUE, not just NOT NULL: no two suppliers may share a contact address
phone VARCHAR(20) NULL, -- Optional; not every supplier record needs a phone number on file
created_at DATETIME2 NOT NULL DEFAULT GETDATE() -- Stamped automatically the moment the row is inserted
);
Confirmation

Click Run to see what this code prints.

Step 2: Create the Products Table

`sku` (stock keeping unit) is what a human actually looks up on a shelf label, so it gets `UNIQUE` even though `product_id` remains the primary key everything else references — the same "surrogate key plus a separately-unique human-facing code" pattern used for `suppliers.contact_email`. The `CHECK` constraint on `unit_price` stops an obviously invalid value (a negative price) from ever being written, no matter what application code eventually inserts into this table. `supplier_id` is the foreign key that ties every product back to the supplier that provides it.

CREATE TABLE products (
product_id INT IDENTITY(1,1) PRIMARY KEY, -- Surrogate key, referenced by stock_levels.product_id
supplier_id INT NOT NULL, -- FK: which supplier provides this product
sku VARCHAR(20) NOT NULL UNIQUE, -- Human-facing code printed on the shelf label, e.g. 'SKU-CBL-01'
product_name NVARCHAR(150) NOT NULL,
unit_price DECIMAL(10,2) NOT NULL, -- DECIMAL, not FLOAT, so money never suffers rounding error
CONSTRAINT FK_products_suppliers FOREIGN KEY (supplier_id)
REFERENCES suppliers(supplier_id) ON DELETE NO ACTION, -- Can't delete a supplier that still has products on file
CONSTRAINT CK_products_price CHECK (unit_price >= 0) -- A product can never have a negative price
);

Step 3: Create the Stock Levels Table

Every column that matters here is either the foreign key back to `products` or a number the warehouse cares about. `quantity_on_hand` is protected by a `CHECK` constraint so a buggy decrement (shipping more units than exist) can never quietly write a negative count. `reorder_threshold` is what the low-stock report in the Sample Queries section compares against, so the business logic for "needs reordering" lives entirely in a query, not scattered across application code.

CREATE TABLE stock_levels (
stock_id INT IDENTITY(1,1) PRIMARY KEY, -- Own surrogate key for this specific stock record
product_id INT NOT NULL UNIQUE, -- FK: one stock row per product (UNIQUE enforces that one-to-one)
quantity_on_hand INT NOT NULL DEFAULT 0, -- Units currently in the warehouse
reorder_threshold INT NOT NULL DEFAULT 10, -- Below this quantity, the product should be reordered
last_restocked DATE NULL, -- NULL until the product has been restocked at least once
CONSTRAINT FK_stock_products FOREIGN KEY (product_id)
REFERENCES products(product_id) ON DELETE CASCADE, -- Deleting a product cleans up its stock row too
CONSTRAINT CK_stock_nonnegative CHECK (quantity_on_hand >= 0) -- Stock can never go below zero, no matter what decremented it
);
Why not just add quantity_on_hand to products?

A single stock number per product works for this project, but a real warehouse often tracks quantity per storage location or per warehouse. Keeping `stock_levels` as its own table now means that if the business later needs a `location_id` column, it is a change to one focused table instead of a redesign of `products` itself.

Step 4: Insert Sample Data

Insert order matters here just like it does in any schema with foreign keys: `suppliers` first, then `products` (since every product needs a real `supplier_id`), and `stock_levels` last of all, since every stock row needs a real `product_id`.

INSERT INTO suppliers (supplier_name, contact_email, phone) VALUES
('Northwind Traders', 'orders@northwindtraders.example', '555-0101'),
('Global Parts Co.', 'sales@globalparts.example', '555-0102');
INSERT INTO products (supplier_id, sku, product_name, unit_price) VALUES
(1, 'SKU-CBL-01', 'USB-C Cable 1m', 6.99),
(1, 'SKU-CBL-02', 'USB-C Cable 2m', 9.99),
(2, 'SKU-ADP-01', 'HDMI to USB-C Adapter', 14.50);
-- One stock row per product, matching the UNIQUE constraint from Step 3
INSERT INTO stock_levels (product_id, quantity_on_hand, reorder_threshold, last_restocked) VALUES
(1, 250, 50, '2026-07-20'),
(2, 8, 25, '2026-06-15'), -- Below its reorder_threshold of 25: needs reordering
(3, 40, 10, '2026-08-01');
Confirmation

Click Run to see what this code prints.

Step 5: Join Across All Three Tables

This is the query that shows why splitting the data into three tables pays off: it starts from `products`, joins to `suppliers` to resolve `supplier_id` into an actual company name, and joins to `stock_levels` to attach the current quantity — turning three tables of raw IDs into one readable inventory report.

SELECT
p.sku,
p.product_name,
s.supplier_name,
sl.quantity_on_hand,
sl.reorder_threshold
FROM products p
JOIN suppliers s ON p.supplier_id = s.supplier_id -- Resolve supplier_id into the supplier's actual name
JOIN stock_levels sl ON p.product_id = sl.product_id -- Attach the current stock quantity
ORDER BY p.sku;
skuproduct_namesupplier_namequantity_on_handreorder_threshold
SKU-ADP-01HDMI to USB-C AdapterGlobal Parts Co.4010
SKU-CBL-01USB-C Cable 1mNorthwind Traders25050
SKU-CBL-02USB-C Cable 2mNorthwind Traders825

Complete Schema

The full schema assembled from the three steps above, ready to run top to bottom in SQL Server Management Studio or Azure Data Studio.

CREATE TABLE suppliers (
supplier_id INT IDENTITY(1,1) PRIMARY KEY,
supplier_name NVARCHAR(100) NOT NULL,
contact_email NVARCHAR(100) NOT NULL UNIQUE,
phone VARCHAR(20) NULL,
created_at DATETIME2 NOT NULL DEFAULT GETDATE()
);
CREATE TABLE products (
product_id INT IDENTITY(1,1) PRIMARY KEY,
supplier_id INT NOT NULL,
sku VARCHAR(20) NOT NULL UNIQUE,
product_name NVARCHAR(150) NOT NULL,
unit_price DECIMAL(10,2) NOT NULL,
CONSTRAINT FK_products_suppliers FOREIGN KEY (supplier_id)
REFERENCES suppliers(supplier_id) ON DELETE NO ACTION,
CONSTRAINT CK_products_price CHECK (unit_price >= 0)
);
CREATE TABLE stock_levels (
stock_id INT IDENTITY(1,1) PRIMARY KEY,
product_id INT NOT NULL UNIQUE,
quantity_on_hand INT NOT NULL DEFAULT 0,
reorder_threshold INT NOT NULL DEFAULT 10,
last_restocked DATE NULL,
CONSTRAINT FK_stock_products FOREIGN KEY (product_id)
REFERENCES products(product_id) ON DELETE CASCADE,
CONSTRAINT CK_stock_nonnegative CHECK (quantity_on_hand >= 0)
);

Sample Queries

Two more queries a warehouse manager would actually run against this schema: a low-stock report using `TOP N`, and a per-supplier product count.

-- Products at or below their reorder threshold, most urgent first
SELECT TOP 5
p.sku,
p.product_name,
sl.quantity_on_hand,
sl.reorder_threshold
FROM stock_levels sl
JOIN products p ON sl.product_id = p.product_id
WHERE sl.quantity_on_hand <= sl.reorder_threshold
ORDER BY sl.quantity_on_hand ASC;
skuproduct_namequantity_on_handreorder_threshold
SKU-CBL-02USB-C Cable 2m825
-- How many products each supplier provides, including suppliers with zero products
SELECT s.supplier_name, COUNT(p.product_id) AS total_products
FROM suppliers s
LEFT JOIN products p ON s.supplier_id = p.supplier_id -- LEFT JOIN keeps suppliers even with no products yet
GROUP BY s.supplier_name
ORDER BY total_products DESC;
supplier_nametotal_products
Northwind Traders2
Global Parts Co.1

Extend This Project

  • Add a `warehouses` table and a `warehouse_id` column on `stock_levels` to track stock per location.
  • Add a `purchase_orders` table so a low-stock alert can be turned into an actual reorder record.
  • Add a `categories` table and a `category_id` foreign key on `products` to support catalog browsing.
  • Create a `VIEW` called `low_stock_products` that packages the reorder report as a queryable view.
  • Add a trigger that updates `stock_levels.last_restocked` automatically whenever quantity_on_hand increases.

Summary

You designed a three-table T-SQL schema that separates suppliers, products, and stock into focused tables connected by real foreign keys, with `CHECK` constraints making invalid states (negative prices, negative stock) structurally impossible rather than trusting application code to prevent them. The join queries you wrote — resolving IDs into a readable report and filtering for low stock with `TOP N` — are the same query shapes you will reuse in almost any inventory or catalog schema, not just this one.