Best Practices and Common Mistakes
A shared kitchen stays usable when everyone follows a few rules: label the jars, put knives back where they belong, and clean as you go. A database is a kitchen that many people, and many programs, share for years.
MySQL will happily accept messy tables and careless queries, and they work, until the day they don't: when the data grows, when a second developer joins, or when someone runs the wrong DELETE. This lesson collects the habits that prevent those days.
Design the tables carefully
Most problems are cheaper to prevent in the design than to fix later. You've learned the pieces already:
- Name things clearly. Use
snake_case, plural table names (customers,orders), and names that say what's inside:ordered_on, notd. Avoid reserved words likeorder,rank, andlibrary, since MySQL 9 reserved that last one. (See Databases, Tables, and MySQL Data Types.) - Give every table a primary key, usually an
INT AUTO_INCREMENT(orBIGINTwhen it could pass two billion). - Choose exact types.
DECIMALfor money, neverFLOAT.DATEandDATETIMEfor dates, never text.TINYINT,SMALLINT, orINT, sized to the data.VARCHARwith a sensible limit. - Say
NOT NULLwhenever a value is required, and give sensibleDEFAULTs. A column that's allowed to be empty needs an answer to the question "what does empty mean?" - Use constraints.
UNIQUEfor emails and codes,CHECKfor rules likeprice >= 0, andFOREIGN KEYfor relationships, as in AUTO_INCREMENT and Keys. - Use InnoDB and utf8mb4, which are the defaults, and never change them without a reason. (InnoDB, Transactions, and Locking, Character Sets and utf8mb4)
- One fact, one place. Don't store the same information in two tables, or a list in one cell. See Database Design.
- Leave strict mode on. MySQL's default settings refuse bad data. Some old tutorials tell you to switch strict mode off to make an error go away. Don't. Fix the data instead.
Write careful queries
Name your columns. SELECT * fetches every column, including ones you don't need, and breaks code when someone adds a column. INSERT INTO products VALUES (...) breaks the day the table gets a new column. List the columns:
INSERT INTO products (name, price) VALUES ('Notebook', 45.50);
SELECT id, name, price FROM products WHERE price < 100;Filter in the database. Let WHERE do the filtering, not your PHP code. Fetching a million rows to keep ten wastes time and memory.
Always ORDER BY when you use LIMIT. Without an order, "the first 10 rows" can be any 10, and can change from run to run.
Never ORDER BY RAND() on a big table. It sorts every row. To pick a random row from a big table, use a smarter approach, like choosing a random ID in your program.
Keep functions off indexed columns. WHERE YEAR(ordered_on) = 2026 can't use an index on ordered_on, and the equivalent range can. See Query Performance with EXPLAIN.
Paginate carefully. LIMIT 20 OFFSET 100000 reads and throws away 100,000 rows. For long lists, "give me 20 rows with an id greater than the last one I showed" is much faster.
Use prepared statements for every value that comes from a person. That's your defense against SQL injection (Using MySQL from PHP with PDO).
Change data safely
The most expensive mistakes come from UPDATE and DELETE without a proper WHERE. Habits that prevent them:
- Write the
SELECTfirst. Before youDELETE FROM orders WHERE status = 'cancelled', runSELECT COUNT(*) FROM orders WHERE status = 'cancelled'. Does the number match what you expected? - Use a transaction for changes you'd like to check:
START TRANSACTION;, run the statement, look at the results, thenCOMMITif they look right, orROLLBACKif not. - Take a backup first before big changes (Backups with mysqldump).
- Let the client protect you. Starting the
mysqlclient with--safe-updates(or-U) refuses anUPDATEorDELETEthat doesn't use a key in itsWHEREclause, or aLIMIT. It's a great habit on a real server.
START TRANSACTION;
DELETE FROM orders WHERE status = 'cancelled';
SELECT COUNT(*) FROM orders;
-- Looks wrong? ROLLBACK; Looks right? COMMIT;Design for growth and change
- Add indexes for the queries you really run, and check them with
EXPLAIN. Don't add them on a hunch: each one slows down writes. - Keep transactions short, and retry after a deadlock (InnoDB, Transactions, and Locking).
- Keep your schema in files. Save your
CREATE TABLEstatements, and every laterALTER TABLE, in.sqlfiles in your project (and in Git), so anyone can rebuild the database. Such files are often called migrations. - Look before you change a big table.
ALTER TABLEon a table with millions of rows can take a long time, and lock things while it works. Try it on a copy first.
Keep it secure and recoverable
- One account per application, with the least privilege it needs, never root (Users and Privileges).
- Keep passwords out of code in Git. Put them in a private settings file.
- Don't open the server to the world. A database on the internet gets attacked within minutes. Let it listen only on the local machine, or a private network, unless you truly need otherwise.
- Back up automatically, keep copies elsewhere, and test your restores (Backups with mysqldump).
- Keep MySQL updated. Each LTS release gets security fixes. If you're on an old version, plan your upgrade before support ends.
The mistakes almost everyone makes
When something's wrong and you can't see why, run down this list. Every one of these came up somewhere in this track.
- Using
||to join text. In MySQL it means OR. UseCONCAT. (Coming from SQLite) - Double quotes around a column name, which SQLite accepts but MySQL reads as text. Use backticks for names, and single quotes for text. (Coming from SQLite)
- Using
FLOATfor money, and watching totals drift. UseDECIMAL. (Databases, Tables, and MySQL Data Types) - Naming things with reserved words, such as
order,rank, or, in MySQL 9,library. (Databases, Tables, and MySQL Data Types) - Expecting
AUTO_INCREMENTto be gap-free, or trying to reuse deleted numbers. (AUTO_INCREMENT and Keys) - Deleting a parent row before its children, and hitting a foreign key error. (AUTO_INCREMENT and Keys)
REPLACE INTOinstead of an upsert, which deletes the row and breaks its ID and links. (Upserts)GROUP_CONCATresults cut off at 1,024 characters. (MySQL Functions)- Putting a SET or JSON column in charge of data you search often, when a proper table would do. (ENUM, SET, and JSON Columns)
- Giving an application the root account. (Users and Privileges)
- Keeping a transaction open while a person thinks, so other people's updates wait and time out. (InnoDB, Transactions, and Locking)
- Forgetting
charset=utf8mb4in the connection, and gettingJoséback. (Character Sets and utf8mb4) - Wrapping an indexed column in a function, so the index can't be used. (Query Performance with EXPLAIN)
- Never testing a restore. (Backups with mysqldump)
Try it
This script works, and it prints an answer. But it breaks many of the habits in this lesson. Read it, predict what it prints, and count how many problems you can spot along the way.
Running MySQL here
Running MySQL right in the browser isn't possible yet, because MySQL needs a real server. Once yours is running (see Installing MySQL Server), run this script in the mysql client.
Check your prediction
+-------------------+
| total |
+-------------------+
| 90.80000019073486 |
+-------------------+
+------+--------+------+------------+------+
| id | n | p | d | s |
+------+--------+------+------------+------+
| 3 | Marker | 30.1 | 2026-10-01 | 1 |
| 4 | Ruler | 15.2 | 2026-10-05 | 1 |
+------+--------+------+------------+------+Some of the problems:
- the names
stuff,n,p,d, andssay nothing (issthe stock, or the status?); - there's no primary key, so nothing stops two rows having
id1; - prices are
FLOAT, so the total has an error in its last digits; - dates are
VARCHAR, so nothing stops'2026-13-45'or'tomorrow', and searching by month needsLIKEon text; - nothing is
NOT NULL, andVARCHAR(255)was picked without a thought; - the
INSERTdoesn't list its columns, and theSELECTuses*.
Here's one way to clean it up. It answers the same questions:
CREATE TABLE products (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(60) NOT NULL,
price DECIMAL(8, 2) NOT NULL CHECK (price >= 0),
added_on DATE NOT NULL,
is_in_stock BOOLEAN NOT NULL DEFAULT TRUE
);
INSERT INTO products (name, price, added_on, is_in_stock) VALUES
('Notebook', 45.50, '2026-09-03', TRUE),
('Pen', 12.10, '2026-09-05', FALSE),
('Marker', 30.10, '2026-10-01', TRUE),
('Ruler', 15.20, '2026-10-05', TRUE);
SELECT SUM(price) AS total FROM products WHERE is_in_stock;
SELECT id, name, price, added_on
FROM products
WHERE added_on >= '2026-10-01' AND added_on < '2026-11-01';+-------+
| total |
+-------+
| 90.80 |
+-------+
+----+--------+-------+------------+
| id | name | price | added_on |
+----+--------+-------+------------+
| 3 | Marker | 30.10 | 2026-10-01 |
| 4 | Ruler | 15.20 | 2026-10-05 |
+----+--------+-------+------------+It's a little longer, and that's fine. The total is exact, the dates are real dates, bad rows are refused, and the names say what everything is.
Try it yourself
- In the messy version, insert a row with the date
'next week'. Does MySQL complain? Now try the same in the clean version. - In the clean version, try inserting a product with a negative price. What happens, and which line makes that happen?
- Pick a table from earlier in this track. Find one unclear name, one column with a poor type, and one query that uses
SELECT *, and fix all three.
Check your understanding
Up next
You've learned everything you need to design, protect, and use a real MySQL database. Time to prove it, in the Final Project: Online Store Database.
