MySQL Data Types Overview
Choosing the right data type for each column is one of the most important decisions in database design. The right type saves storage, improves query performance, enforces data integrity, and makes your code more readable. MySQL has a rich type system covering numbers, strings, dates, spatial data, and JSON.
Data Type Categories
Category | Types | Use for |
|---|---|---|
Numeric | TINYINT, SMALLINT, MEDIUMINT, INT, BIGINT, FLOAT, DOUBLE, DECIMAL, BIT, BOOLEAN | Counts, IDs, prices, measurements, flags |
String | CHAR, VARCHAR, TINYTEXT, TEXT, MEDIUMTEXT, LONGTEXT, BINARY, VARBINARY, BLOB types, ENUM, SET | Names, descriptions, codes, binary data, categories |
Date and Time | DATE, TIME, DATETIME, TIMESTAMP, YEAR | Dates, times, timestamps, durations |
Spatial | GEOMETRY, POINT, LINESTRING, POLYGON, MULTIPOINT, and more | Geographic coordinates, shapes, GIS data |
JSON | JSON | Semi-structured data, configuration, flexible attributes |
Complete Storage Size Comparison
Type | Storage | Range (Signed) | Range (Unsigned) |
|---|---|---|---|
TINYINT | 1 byte | -128 to 127 | 0 to 255 |
SMALLINT | 2 bytes | -32,768 to 32,767 | 0 to 65,535 |
MEDIUMINT | 3 bytes | -8,388,608 to 8,388,607 | 0 to 16,777,215 |
INT | 4 bytes | -2,147,483,648 to 2,147,483,647 | 0 to 4,294,967,295 |
BIGINT | 8 bytes | -9.2 × 10^18 to 9.2 × 10^18 | 0 to 18.4 × 10^18 |
FLOAT | 4 bytes | Approx ±3.4 × 10^38 | Same (positive only) |
DOUBLE | 8 bytes | Approx ±1.8 × 10^308 | Same (positive only) |
DECIMAL(p,s) | Varies: ceil((p-s)/9)*4 + ceil(s/9)*4 bytes | Exact up to 65 digits | Exact up to 65 digits |
CHAR(n) | Fixed: n * bytes_per_char | 1–255 chars | n/a |
VARCHAR(n) | Actual length + 1-2 bytes overhead | 0–65,535 bytes total | n/a |
DATE | 3 bytes | 1000-01-01 to 9999-12-31 | n/a |
TIME | 3 bytes | -838:59:59 to 838:59:59 | n/a |
DATETIME | 8 bytes | 1000-01-01 to 9999-12-31 23:59:59 | n/a |
TIMESTAMP | 4 bytes | 1970-01-01 UTC to 2038-01-19 | n/a |
TEXT | 2 bytes + content | Up to 65,535 bytes (64 KB) | n/a |
MEDIUMTEXT | 3 bytes + content | Up to 16,777,215 bytes (16 MB) | n/a |
LONGTEXT | 4 bytes + content | Up to 4 GB | n/a |
JSON | Binary format, similar to LONGBLOB | Up to max_allowed_packet | n/a |
Implicit Type Coercion Rules and Gotchas
When MySQL compares or combines values of different types, it silently converts one type to the other. This coercion can produce surprising results and — critically — can prevent index usage.
-- String to number: leading digits extracted, rest discarded SELECT '42abc' + 0; -- 42 (string becomes 42) SELECT '42abc' = 42; -- 1 (TRUE — string converts to 42) SELECT '0abc' = 0; -- 1 (TRUE — '0abc' converts to 0) -- This silently matches unintended rows! SELECT * FROM users WHERE id = '1 OR 1=1'; -- MySQL converts '1 OR 1=1' to 1 (stops at non-digit) -- Finds only user id=1 — NOT an injection issue here, but a logic bug -- Type mismatch prevents index use -- Bad: index on id (INT) cannot be used with string literal EXPLAIN SELECT * FROM users WHERE id = '42'G -- type: ref or ALL depending on version (implicit cast) -- Good: matches column type exactly, index used efficiently EXPLAIN SELECT * FROM users WHERE id = 42G
NULL Storage
InnoDB stores NULL values efficiently. For each row, InnoDB maintains a null bitmap in the row header — 1 bit per nullable column. If a column IS NULL, the bitmap bit is set and no additional storage is used for the value itself.
For a NOT NULL column, there is no null bitmap bit — saving 1 bit per column per row. On a table with 10 nullable columns and 100 million rows, this adds up. More importantly, NOT NULL columns enable certain optimizer shortcuts and are slightly faster to compare.
-- Declaring NOT NULL enables the optimizer to skip null checks CREATE TABLE events ( id BIGINT NOT NULL AUTO_INCREMENT, user_id INT NOT NULL, -- no null check needed in joins event_type VARCHAR(50) NOT NULL, payload JSON, -- nullable: only allocate when present created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) );
The Cost of Wrong Types
Storing phone numbers as INT: loses leading zeros (0800... becomes 800...), cannot store international + prefix.
Storing prices as FLOAT: rounding errors make 9.99 * 100 = 999.00000190... instead of 999.00.
Storing dates as VARCHAR: sorting, date arithmetic, and range queries all break.
Storing booleans as VARCHAR('true'/'false'): 4-5 bytes instead of 1 bit; slow comparisons.
Using INT instead of BIGINT for high-traffic tables: hits the 2.1B row ceiling unexpectedly.
Use DECIMAL for Money, Never FLOAT
-- This is wrong — FLOAT has rounding errors CREATE TABLE prices_wrong (price FLOAT); INSERT INTO prices_wrong VALUES (9.99); SELECT price * 100 FROM prices_wrong; -- Result: 999.0000019073486 -- NOT 999.00! -- This is correct CREATE TABLE prices_correct (price DECIMAL(10, 2)); INSERT INTO prices_correct VALUES (9.99); SELECT price * 100 FROM prices_correct; -- Result: 999.00 -- exact!
CAST() and CONVERT() with All Format Options
-- CAST(value AS type) -- SQL standard
SELECT CAST('2024-01-15' AS DATE);
SELECT CAST(42.7 AS UNSIGNED); -- 42 (truncates, not rounds)
SELECT CAST('123abc' AS UNSIGNED); -- 123 (stops at non-digit)
SELECT CAST(NOW() AS DATE); -- strips time portion
SELECT CAST('{"a":1}' AS JSON); -- validates and parses
-- All CAST target types:
-- SIGNED INT with sign
-- UNSIGNED INT without sign
-- DECIMAL(p,s) Exact decimal
-- FLOAT 4-byte float
-- DOUBLE 8-byte float
-- DATE Date only
-- DATETIME Date and time
-- TIME Time only
-- YEAR Year only
-- CHAR[(n)] Character string
-- BINARY[(n)] Binary string
-- JSON JSON document (8.0+)
-- CONVERT(value, type) -- MySQL extension, functionally same as CAST
SELECT CONVERT('42', UNSIGNED);
SELECT CONVERT(NOW(), DATE);
-- Two-argument CONVERT: character set conversion
SELECT CONVERT('hello' USING utf8mb4);
SELECT CONVERT(latin1_col USING utf8mb4) FROM legacy_table;Generated Columns
Generated columns compute their value automatically from an expression involving other columns. They come in two flavours: VIRTUAL (computed on read, not stored) and STORED (computed on write, stored on disk). Both can be indexed, which enables fast queries on derived values.
-- VIRTUAL column: computed on SELECT, not stored
CREATE TABLE order_items (
id INT NOT NULL AUTO_INCREMENT,
qty INT NOT NULL,
unit_price DECIMAL(10,2) NOT NULL,
-- VIRTUAL: no storage used, computed each time it is read
line_total DECIMAL(12,2) AS (qty * unit_price) VIRTUAL,
PRIMARY KEY (id)
);
-- STORED column: computed on INSERT/UPDATE, stored on disk
CREATE TABLE articles (
id INT NOT NULL AUTO_INCREMENT,
title VARCHAR(500) NOT NULL,
body MEDIUMTEXT,
-- STORED: indexable, takes disk space
word_count INT UNSIGNED AS (
IF(body IS NULL, 0,
CHAR_LENGTH(body) - CHAR_LENGTH(REPLACE(body, ' ', '')) + 1)
) STORED,
PRIMARY KEY (id),
INDEX idx_word_count (word_count) -- can index STORED generated columns
);
-- Query using the generated column
SELECT title, word_count FROM articles WHERE word_count > 1000;JSON vs TEXT for Structured Data
The JSON type (MySQL 5.7.8+) stores JSON documents in an efficient binary format that:
- Validates JSON on insert (rejects malformed JSON)
- Supports path extraction with the
->and->>operators - Allows indexing via generated columns on specific JSON paths
- Is more compact than equivalent TEXT storage for the same document
Use TEXT only for unstructured free-form content that you never need to parse.
-- JSON column: validated, path-accessible
CREATE TABLE user_preferences (
user_id INT PRIMARY KEY,
settings JSON NOT NULL
);
INSERT INTO user_preferences VALUES
(1, '{"theme":"dark","lang":"en","notifications":true}');
-- Extract with -> (returns JSON value, strings are quoted)
SELECT settings->'$.theme' FROM user_preferences WHERE user_id = 1;
-- Result: "dark"
-- Extract with ->> (returns unquoted string)
SELECT settings->>'$.theme' FROM user_preferences WHERE user_id = 1;
-- Result: dark
-- Index a specific JSON path via generated column
ALTER TABLE user_preferences
ADD COLUMN theme_virtual VARCHAR(20)
AS (settings->>'$.theme') VIRTUAL,
ADD INDEX idx_theme (theme_virtual);
SELECT user_id FROM user_preferences WHERE theme_virtual = 'dark';Type Selection Quick Reference
Data | Recommended Type | Reason |
|---|---|---|
Auto-increment PK (small) | INT UNSIGNED AUTO_INCREMENT | Up to 4.3 billion rows, 4 bytes |
Auto-increment PK (large) | BIGINT UNSIGNED AUTO_INCREMENT | Up to 18.4 × 10^18 rows, 8 bytes |
UUID / GUID | BINARY(16) or CHAR(36) | BINARY saves 20 bytes per row |
Boolean / flag | TINYINT(1) or BOOLEAN | MySQL has no true boolean |
Money / currency | DECIMAL(15, 2) | Exact; never FLOAT/DOUBLE |
Email address | VARCHAR(254) | RFC 5321 max is 254 chars |
URL | VARCHAR(2048) | Covers most real-world URLs |
Password hash (bcrypt) | CHAR(60) | bcrypt output is always 60 chars |
ISO country code | CHAR(2) | Always exactly 2 chars |
Long text / article body | MEDIUMTEXT or LONGTEXT | TEXT capped at 64KB |
Timestamp with auto-update | TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | Tracks row changes, UTC storage |
Date without time | DATE | 3 bytes, no time component |
Flexible attributes | JSON | Validated, path-accessible, indexable |
Fixed category list | ENUM or TINYINT UNSIGNED | ENUM stored as 1-2 bytes internally |