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
| Topic | Recommendation | Example |
|---|---|---|
| Case | All lowercase with underscores (snake_case) | order_items, created_at |
| Characters | Letters, digits and _. No spaces, hyphens or quotes | shipping_address (not Shipping Address) |
| Language | One language, usually English | customers |
| Table names | Plural nouns (a table holds many rows) or singular, pick one and keep it | customers, orders |
| Junction tables | Both table names, or the relationship | book_authors, enrollments |
| Primary key | id | customers.id |
| Foreign keys | <singular table>_id | orders.customer_id |
| Booleans | is_ or has_ prefix | is_active, has_paid |
| Dates and times | _at for timestamps, _on for dates | created_at, due_on |
| Amounts | The unit in the name when it is not obvious | price_cents, weight_kg |
| Constraints and indexes | A prefix and the columns | uq_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:
SELECT word, reserved FROM information_schema.keywords WHERE word IN ('ORDER', 'GROUP', 'KEY', 'DESC', 'RANK', 'USER', 'STATUS', 'NAME', 'LIMIT', 'INDEX') ORDER BY word;
+--------+----------+
| 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:
CREATE TABLE rank (id INT);
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
| Weak | Better |
|---|---|
data, info, value, flag | profile_json, birth_date, is_verified |
tbl_customers, t_orders | customers, orders (the type is obvious from the position in SQL) |
col1, field_2 | A real name |
customerNameAndAddress | Two columns, name and address |
date | order_date, created_at (and date is also a type name) |
status with values 0, 1, 2 | An 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:
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;
+-------------+-----------------------------+
| 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):
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;
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:
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;
+-----------------+-----------------+
| 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
| Object | Pattern |
|---|---|
| Primary key | PRIMARY (fixed by MySQL) |
| Unique key | uq_<table>_<columns> |
| Index | idx_<table>_<columns> |
| Foreign key | fk_<child>_<parent> or fk_<child>_<column> |
| Check | chk_<table>_<rule> |
| View | v_<name> or <name>_view (be consistent) |
| Stored procedure | sp_<verb>_<noun> |
| Trigger | trg_<table>_<event> |