Skip to content

MySQL Tutorial Database Design

Naming Conventions for Tables and Columns

Lesson 270 of 308 4 min read By Deepak Paswan Updated Oct 5, 2026

Names are the user interface of your database. People read them in queries, error messages and migration files for years. There is no single official standard, so the best convention is the one the whole team follows consistently. These are common and sensible choices.

The rules most teams settle on

TopicRecommendationExample
CaseAll lowercase with underscores (snake_case)order_items, created_at
CharactersLetters, digits and _. No spaces, hyphens or quotesshipping_address (not Shipping Address)
LanguageOne language, usually Englishcustomers
Table namesPlural nouns (a table holds many rows) or singular, pick one and keep itcustomers, orders
Junction tablesBoth table names, or the relationshipbook_authors, enrollments
Primary keyidcustomers.id
Foreign keys<singular table>_idorders.customer_id
Booleansis_ or has_ prefixis_active, has_paid
Dates and times_at for timestamps, _on for datescreated_at, due_on
AmountsThe unit in the name when it is not obviousprice_cents, weight_kg
Constraints and indexesA prefix and the columnsuq_users_email, idx_orders_customer_id, fk_orders_customer

Why lowercase matters

On Linux, MySQL treats table names as case-sensitive (the file names on disk are). On Windows and macOS they are not by default (Case Sensitivity). A project that develops on Windows with Customers and deploys on Linux with customers breaks in production. Using only lowercase everywhere removes the problem entirely.

Avoid reserved words

A reserved word cannot be used as a name without backticks. Many everyday words are reserved. Ask the server:

query.sql
SQL
SELECT word, reserved FROM information_schema.keywords WHERE word IN ('ORDER', 'GROUP', 'KEY', 'DESC', 'RANK', 'USER', 'STATUS', 'NAME', 'LIMIT', 'INDEX') ORDER BY word;
Output
OUTPUT
+--------+----------+
| WORD   | RESERVED |
+--------+----------+
| DESC   |        1 |
| GROUP  |        1 |
| INDEX  |        1 |
| KEY    |        1 |
| LIMIT  |        1 |
| NAME   |        0 |
| ORDER  |        1 |
| RANK   |        1 |
| STATUS |        0 |
| USER   |        0 |
+--------+----------+

(RESERVED = 1 means reserved. This is the MySQL 8.4 result. The list changes between versions, as RANK shows: it became reserved in 8.0.2, so a table that worked in 5.7 can break after an upgrade. NAME, STATUS and USER are allowed unquoted.)

The same table, then, cannot be named or aliased with those reserved words:

query.sql
SQL
CREATE TABLE rank (id INT);
Output
OUTPUT
ERROR 1064 (42000): You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'rank (id INT)'

Quoting with backticks makes it work, but then every query forever needs the backticks, and generated SQL and tools forget them. Choose a different name: ranking, sort_order, user_group.

Names that hide meaning

WeakBetter
data, info, value, flagprofile_json, birth_date, is_verified
tbl_customers, t_orderscustomers, orders (the type is obvious from the position in SQL)
col1, field_2A real name
customerNameAndAddressTwo columns, name and address
dateorder_date, created_at (and date is also a type name)
status with values 0, 1, 2An ENUM, or a lookup table with readable names (ENUM)

Consistency of types: the quiet bug

A column that means the same thing should have the same type everywhere. If customers.id is INT UNSIGNED, then orders.customer_id must also be INT UNSIGNED. A mismatch breaks foreign keys, or silently prevents an index from being used in a join (Type Conversion). Make a schema audit part of your routine:

query.sql
SQL
CREATE TABLE customers (id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL);
CREATE TABLE orders (id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, customer_id INT NOT NULL, created_at DATETIME NOT NULL);
CREATE TABLE invoices (id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, customer_id BIGINT NOT NULL, created TIMESTAMP NULL);

SELECT column_name, GROUP_CONCAT(CONCAT(table_name, ':', column_type) ORDER BY table_name SEPARATOR '  ') AS definitions
FROM information_schema.columns
WHERE table_schema = DATABASE() AND column_name IN ('id', 'customer_id')
GROUP BY column_name
HAVING COUNT(DISTINCT column_type) > 1;
Output
OUTPUT
+-------------+-----------------------------+
| COLUMN_NAME | definitions                 |
+-------------+-----------------------------+
| customer_id | invoices:bigint  orders:int |
+-------------+-----------------------------+
1 row in set

The audit shows customer_id defined as int in one table and bigint in another, a mismatch to fix. (The id columns agree, so they do not appear. Notice also that created_at and created mean the same thing in two tables, which this query cannot see: a naming review catches that.)

Another audit finds names that break your own rules (uppercase letters or spaces):

query.sql
SQL
SELECT table_name, column_name FROM information_schema.columns
WHERE table_schema = DATABASE() AND CAST(column_name AS BINARY) REGEXP '[^a-z0-9_]'
ORDER BY table_name, column_name;
Output
OUTPUT
ERROR 3995 (HY000): Character set 'binary' cannot be used in conjunction with 'utf8mb4_0900_ai_ci' in call to regexp_like.

An empty result means all column names are lowercase snake_case.

Name constraints and indexes yourself

If you do not name them, MySQL invents names that appear in error messages and cannot be predicted:

query.sql
SQL
CREATE TABLE members (
    id INT PRIMARY KEY,
    email VARCHAR(100) NOT NULL,
    age INT,
    UNIQUE (email),
    CHECK (age >= 0)
);
SELECT constraint_name, constraint_type FROM information_schema.table_constraints WHERE table_schema = DATABASE() AND table_name = 'members' ORDER BY constraint_type, constraint_name;
Output
OUTPUT
+-----------------+-----------------+
| CONSTRAINT_NAME | CONSTRAINT_TYPE |
+-----------------+-----------------+
| members_chk_1   | CHECK           |
| PRIMARY         | PRIMARY KEY     |
| email           | UNIQUE          |
+-----------------+-----------------+
3 rows in set

members_chk_1 and email tell you nothing when a migration or an error message mentions them. Write CONSTRAINT chk_members_age CHECK (age >= 0) and UNIQUE KEY uq_members_email (email) so that they say what they protect (Managing Constraints).

Prefixes for objects

ObjectPattern
Primary keyPRIMARY (fixed by MySQL)
Unique keyuq_<table>_<columns>
Indexidx_<table>_<columns>
Foreign keyfk_<child>_<parent> or fk_<child>_<column>
Checkchk_<table>_<rule>
Viewv_<name> or <name>_view (be consistent)
Stored proceduresp_<verb>_<noun>
Triggertrg_<table>_<event>
lightbulb
Write the convention down
Put the rules in one short page of the repository. New team members, and your future self, will follow them, and code reviews become a check against the page instead of an argument about taste.