MySQL Data Types: Ranges, Storage and Which One to Use

MySQL data types come in five families: numeric, date and time, string, JSON and spatial. Here’s one table that uses the types you’ll reach for most days:

CREATE TABLE customers (
  customer_id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  full_name   VARCHAR(40)   NOT NULL,
  state       CHAR(2)       NOT NULL,
  zip_code    CHAR(5)       NOT NULL,
  plan        ENUM('free', 'pro', 'team') NOT NULL DEFAULT 'free',
  is_active   BOOLEAN       NOT NULL DEFAULT TRUE,
  balance     DECIMAL(10,2) NOT NULL DEFAULT 0.00,
  signup_date DATE          NOT NULL,
  last_login  DATETIME      NULL,
  prefs       JSON          NULL
);

Ten columns, nine different types. You’ll see this table again in each section, or a small scratch table when I need to break something on purpose.

Tested on MySQL 8.4.9 Community (the 8.4 LTS line) on Windows 11, run with the mysql client from VS Code. Ranges and byte counts come from the MySQL 8.4 reference manual.

Sample Table for the MySQL Data Type Examples

Six US customers. You run this after the CREATE TABLE above:

INSERT INTO customers
  (full_name, state, zip_code, plan, is_active, balance, signup_date, last_login, prefs)
VALUES
('Hannah Brooks', 'MA', '02134', 'pro', TRUE, 1250.00,
 '2026-01-12', '2026-03-02 08:15:00', '{"newsletter": true, "theme": "dark"}'),
('Tyler Reed', 'OH', '43215', 'free', TRUE, 0.00,
 '2026-02-03', NULL, NULL),
('Grace Miller', 'CA', '94110', 'team', TRUE, 2890.75,
 '2026-02-20', '2026-03-01 17:40:00', '{"newsletter": false}'),
('Marcus Bennett', 'NJ', '07030', 'pro', FALSE, 310.50,
 '2026-03-08', NULL, NULL),
('Olivia Hayes', 'TX', '78701', 'free', TRUE, 640.25,
 '2026-04-15', '2026-04-20 12:05:00', '{"theme": "light"}'),
('Ethan Cole', 'CT', '06103', 'pro', TRUE, 1975.00,
 '2026-05-01', NULL, NULL);
SELECT COUNT(*) AS customers FROM customers;

Row count:

+-----------+
| customers |
+-----------+
|         6 |
+-----------+

New to table syntax? Start with how to create a table in MySQL, then come back here to pick the types.

One setting changes almost every result in this post: sql_mode. My server’s default was STRICT_TRANS_TABLES plus the zero-date checks, so bad values raise errors instead of being quietly clipped.

MySQL Numeric Data Types: Integer Ranges and Storage

Pick the smallest integer that fits your largest real value, then decide if negatives are possible:

TypeBytesSigned rangeUNSIGNED range
TINYINT1-128 to 1270 to 255
SMALLINT2-32,768 to 32,7670 to 65,535
MEDIUMINT3-8,388,608 to 8,388,6070 to 16,777,215
INT4-2,147,483,648 to 2,147,483,6470 to 4,294,967,295
BIGINT8-263 to 263-10 to 264-1 (18,446,744,073,709,551,615)

Now push a value one past the edge. I used --force so the client keeps going after each error:

CREATE TABLE int_test (t TINYINT, tu TINYINT UNSIGNED, n INT(11));
SHOW WARNINGS;
INSERT INTO int_test (t, tu) VALUES (127, 255);
INSERT INTO int_test (t) VALUES (128);
INSERT INTO int_test (tu) VALUES (-1);
SELECT t, tu FROM int_test;

What MySQL returned:

+---------+------+------------------------------------------------------------------------------+
| Level   | Code | Message                                                                      |
+---------+------+------------------------------------------------------------------------------+
| Warning | 1681 | Integer display width is deprecated and will be removed in a future release. |
+---------+------+------------------------------------------------------------------------------+
ERROR 1264 (22003) at line 4: Out of range value for column 't' at row 1
ERROR 1264 (22003) at line 5: Out of range value for column 'tu' at row 1
+------+------+
| t    | tu   |
+------+------+
|  127 |  255 |
+------+------+
MySQL data types: TINYINT out of range ERROR 1264 and the integer display width deprecation warning 1681 in MySQL 8.4
128 doesn’t fit a signed TINYINT, and -1 doesn’t fit an UNSIGNED one

Two things to notice. ERROR 1264 rejected both bad rows, so only 127 and 255 were stored. And INT(11) earned warning 1681: display width on integer types is deprecated in 8.4.

MySQL DECIMAL vs FLOAT and DOUBLE: Which One Rounds?

DECIMAL stores exact digits (up to 65 digits, 30 after the point). FLOAT (4 bytes) and DOUBLE (8 bytes) store binary approximations. Same two amounts, 10 cents and 20 cents, in all three:

CREATE TABLE money_test (d DECIMAL(10,2), f FLOAT, g DOUBLE);
INSERT INTO money_test VALUES (0.10, 0.10, 0.10), (0.20, 0.20, 0.20);
SELECT SUM(d), SUM(f), SUM(g) FROM money_test;
SELECT COUNT(*) AS float_matches FROM money_test WHERE f = 0.1;

What came back:

+--------+---------------------+---------------------+
| SUM(d) | SUM(f)              | SUM(g)              |
+--------+---------------------+---------------------+
|   0.30 | 0.30000000447034836 | 0.30000000000000004 |
+--------+---------------------+---------------------+
+---------------+
| float_matches |
+---------------+
|             0 |
+---------------+
MySQL DECIMAL vs FLOAT vs DOUBLE: SUM returns 0.30 for DECIMAL and 0.30000000447034836 for FLOAT
Only DECIMAL adds 0.10 and 0.20 to exactly 0.30

DECIMAL returned 0.30. Exact. FLOAT returned 0.30000000447034836 and DOUBLE 0.30000000000000004, and WHERE f = 0.1 matched zero rows even though I’d just inserted 0.10 into that very column a line earlier.

DECIMAL rounds too, on the way in. Same scratch table:

INSERT INTO money_test (d) VALUES (19.999);
SHOW WARNINGS;
SELECT d FROM money_test WHERE f IS NULL;

Output:

+-------+------+----------------------------------------+
| Level | Code | Message                                |
+-------+------+----------------------------------------+
| Note  | 1265 | Data truncated for column 'd' at row 1 |
+-------+------+----------------------------------------+
+-------+
| d     |
+-------+
| 20.00 |
+-------+

19.999 in a DECIMAL(10,2) became 20.00 with Note 1265, not an error. If a silent cent matters to you, round in your app first.

Pro Tip: FLOAT(M,D) and UNSIGNED on DECIMAL, FLOAT and DOUBLE are deprecated in 8.4. Use plain FLOAT or DOUBLE, and a CHECK constraint to block negative amounts.

MySQL String Data Types: CHAR, VARCHAR, TEXT and BLOB

TypeMax lengthStorageUse it for
CHAR(M)255 charactersM x bytes per character, paddedFixed codes: state, ZIP
VARCHAR(M)65,535 bytes (row limit)Length + 1 or 2 bytesNames, emails, anything you index
TINYTEXT / TEXT255 B / 64 KBLength + 1 / + 2 bytesNotes, comments
MEDIUMTEXT / LONGTEXT16 MB / 4 GBLength + 3 / + 4 bytesArticles, logs
BINARY / VARBINARY / BLOBSame as the text typesBytes, no character setHashes, files

You’ll want to watch the parentheses and the ZIP code in this scratch table:

CREATE TABLE str_test (c CHAR(4), v VARCHAR(4), zip_int INT, zip CHAR(5));
INSERT INTO str_test VALUES ('ab  ', 'ab  ', 02134, '02134');
SELECT CONCAT('(', c, ')') AS c, CONCAT('(', v, ')') AS v,
       zip_int, zip FROM str_test;
INSERT INTO str_test (v) VALUES ('Texas');

Result:

+------+--------+---------+-------+
| c    | v      | zip_int | zip   |
+------+--------+---------+-------+
| (ab) | (ab  ) |    2134 | 02134 |
+------+--------+---------+-------+
ERROR 1406 (22001) at line 5: Data too long for column 'v' at row 1
MySQL CHAR vs VARCHAR: CHAR drops trailing spaces, INT turns ZIP 02134 into 2134, ERROR 1406 Data too long
CHAR trimmed the spaces, INT lost the ZIP’s leading zero

CHAR gave back (ab) because MySQL strips CHAR’s trailing spaces on retrieval; VARCHAR kept them. Boston’s ZIP 02134 came back as 2134 from the INT column. Then 'Texas' in a VARCHAR(4) hit ERROR 1406.

MySQL VARCHAR vs TEXT

Use VARCHAR when you’ll filter, sort or index the column, or want a literal DEFAULT. TEXT columns can’t take a plain index without a prefix length, and they count only 9 to 12 bytes toward the 65,535-byte row limit.

The MariaDB rules are close; MariaDB VARCHAR walks through them on MariaDB 13.

ENUM and SET Columns in MySQL

An ENUM stores one value from a fixed list (1 byte for up to 255 members). Same customers table, sorted by plan:

SELECT DISTINCT plan, plan + 0 AS idx FROM customers ORDER BY plan;
UPDATE customers SET plan = 'premium' WHERE customer_id = 2;

Output:

+------+-----+
| plan | idx |
+------+-----+
| free |   1 |
| pro  |   2 |
| team |   3 |
+------+-----+
ERROR 1265 (01000) at line 2: Data truncated for column 'plan' at row 1

Sorting follows the list order, not the alphabet, and plan + 0 shows the stored index. The typo 'premium' failed with ERROR 1265 because strict mode was on.

SET is ENUM’s cousin: zero or more members from a list of up to 64. Both need an ALTER TABLE to add a value, so if your list changes often, a lookup table wins. More: ENUM with examples.

MySQL DATETIME vs TIMESTAMP (and DATE, TIME, YEAR)

TypeRangeBytes
DATE1000-01-01 to 9999-12-313
TIME-838:59:59 to 838:59:593 + fractional
DATETIME1000-01-01 00:00:00 to 9999-12-31 23:59:595 + fractional
TIMESTAMP1970-01-01 00:00:01 to 2038-01-19 03:14:07 UTC4 + fractional
YEAR1901 to 21551

Fractional seconds go up to 6 digits, DATETIME(6), and cost 0 to 3 extra bytes. Here’s the difference that bites when your app servers sit in different time zones: I wrote one value at -05:00 (Eastern), then read it back at -08:00 (Pacific):

CREATE TABLE tz_test (dt DATETIME, ts TIMESTAMP);
SET time_zone = '-05:00';
INSERT INTO tz_test VALUES ('2026-03-15 09:30:00', '2026-03-15 09:30:00');
SET time_zone = '-08:00';
SELECT dt, ts FROM tz_test;
INSERT INTO tz_test (dt) VALUES ('2026-02-30 10:00:00');
INSERT INTO tz_test (ts) VALUES ('2038-01-20 00:00:00');
INSERT INTO tz_test (dt) VALUES ('2038-01-20 00:00:00');

Result:

+---------------------+---------------------+
| dt                  | ts                  |
+---------------------+---------------------+
| 2026-03-15 09:30:00 | 2026-03-15 06:30:00 |
+---------------------+---------------------+
ERROR 1292 (22007) at line 6: Incorrect datetime value: '2026-02-30 10:00:00' for column 'dt' at row 1
ERROR 1292 (22007) at line 7: Incorrect datetime value: '2038-01-20 00:00:00' for column 'ts' at row 1
MySQL DATETIME vs TIMESTAMP: TIMESTAMP shifts 09:30 to 06:30 after a time_zone change, ERROR 1292 for 2038
TIMESTAMP followed the session time zone; DATETIME didn’t move

DATETIME didn’t move. TIMESTAMP said 06:30, because MySQL stores it as UTC and converts it back with the session’s time_zone. February 30 failed with ERROR 1292.

The 2038 limit is real: '2038-01-20' was rejected for TIMESTAMP but went into DATETIME without a word. Use DATETIME for contract end dates, birthdays and anything far out; TIMESTAMP columns fit audit stamps.

JSON, BOOLEAN and Spatial Types

JSON validates on insert, and you query it by path. Same customers, one key:

SELECT full_name, prefs->>'$.theme' AS theme
FROM customers WHERE prefs IS NOT NULL;
UPDATE customers SET prefs = '{theme: dark}' WHERE customer_id = 2;
SELECT ST_AsText(ST_GeomFromText('POINT(30.2672 -97.7431)', 4326)) AS austin;

MySQL’s answer:

+---------------+-------+
| full_name     | theme |
+---------------+-------+
| Hannah Brooks | dark  |
| Grace Miller  | NULL  |
| Olivia Hayes  | light |
+---------------+-------+
ERROR 3140 (22032) at line 3: Invalid JSON text: "Missing a name for object member." at position 1 in value for column 'customers.prefs'.
+-------------------------+
| austin                  |
+-------------------------+
| POINT(30.2672 -97.7431) |
+-------------------------+

Grace’s document has no theme key, so she gets NULL. The unquoted {theme: dark} never got in: ERROR 3140. For path functions, see JSON functions with examples.

Spatial types (GEOMETRY, POINT, LINESTRING, POLYGON and their MULTI versions) hold map data with an SRID. With SRID 4326 MySQL reads the point as latitude then longitude, which is why Austin is written 30.2672 first.

BOOLEAN isn’t its own type. It’s a synonym for TINYINT(1), and TRUE and FALSE are just 1 and 0:

CREATE TABLE flag_test (is_active BOOLEAN);
INSERT INTO flag_test VALUES (TRUE), (FALSE), (5);
SELECT is_active, is_active IS TRUE AS truthy FROM flag_test;

Result:

+-----------+--------+
| is_active | truthy |
+-----------+--------+
|         1 |      1 |
|         0 |      0 |
|         5 |      1 |
+-----------+--------+

The 5 went in and counts as true. Want a strict yes/no? Add CHECK (is_active IN (0, 1)).

How to See a Column’s Data Type in MySQL

You can check any table in one line:

DESCRIBE customers;

Result:

+-------------+---------------------------+------+-----+---------+----------------+
| Field       | Type                      | Null | Key | Default | Extra          |
+-------------+---------------------------+------+-----+---------+----------------+
| customer_id | int unsigned              | NO   | PRI | NULL    | auto_increment |
| full_name   | varchar(40)               | NO   |     | NULL    |                |
| state       | char(2)                   | NO   |     | NULL    |                |
| zip_code    | char(5)                   | NO   |     | NULL    |                |
| plan        | enum('free','pro','team') | NO   |     | free    |                |
| is_active   | tinyint(1)                | NO   |     | 1       |                |
| balance     | decimal(10,2)             | NO   |     | 0.00    |                |
| signup_date | date                      | NO   |     | NULL    |                |
| last_login  | datetime                  | YES  |     | NULL    |                |
| prefs       | json                      | YES  |     | NULL    |                |
+-------------+---------------------------+------+-----+---------+----------------+

BOOLEAN shows up as tinyint(1), and the INT key shows as int unsigned with no width. In a script, you’d query information_schema.COLUMNS instead:

SELECT COLUMN_NAME, COLUMN_TYPE, CHARACTER_MAXIMUM_LENGTH AS max_len
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'mysql_types_demo' AND TABLE_NAME = 'customers'
  AND COLUMN_NAME IN ('zip_code', 'plan', 'balance', 'prefs')
ORDER BY ORDINAL_POSITION;

Output:

+-------------+---------------------------+---------+
| COLUMN_NAME | COLUMN_TYPE               | max_len |
+-------------+---------------------------+---------+
| zip_code    | char(5)                   |       5 |
| plan        | enum('free','pro','team') |       4 |
| balance     | decimal(10,2)             |    NULL |
| prefs       | json                      |    NULL |
+-------------+---------------------------+---------+

CHARACTER_MAXIMUM_LENGTH is in characters, so the ENUM reports 4 (the longest member). MariaDB has the same views; show a column’s data type in MariaDB covers SHOW FULL COLUMNS too.

Which MySQL Data Type Should You Use? Quick Picks

ColumnPickWhy
Money (USD)DECIMAL(10,2)Exact cents; 0.10 + 0.20 = 0.30
Auto IDINT UNSIGNED AUTO_INCREMENT4.29 billion rows; BIGINT UNSIGNED past that
Yes/no flagBOOLEANStored as TINYINT(1), 1 byte
EmailVARCHAR(255)Indexable; stores only the real length
US ZIP codeCHAR(5) or VARCHAR(10) for ZIP+4INT drops the leading zero
Phone numberVARCHAR(20)Keeps +, dashes and leading zeros
Business date/timeDATETIMENo time-zone shift, no 2038 limit
Row created/updatedTIMESTAMPStored as UTC
Settings blobJSONValidated, queryable by path

Porting to Postgres? PostgreSQL data types has no UNSIGNED.

MySQL Data Type Errors and Gotchas From the Run

  • ERROR 1264 Out of range value: go one integer size up, or UNSIGNED.
  • ERROR 1406 Data too long: widen the VARCHAR or CHAR.
  • ERROR 1292 Incorrect datetime value: an impossible date, or a TIMESTAMP past 2038-01-19.
  • Note 1265 on DECIMAL: extra decimal places are rounded, not rejected.
  • No error at all with sql_mode off. Then MySQL clips and warns, so check SELECT @@sql_mode first.

Next step: put these picks into a full schema with the MySQL tutorial. Related reads:

Frequently Asked Questions

What is the maximum size of VARCHAR in MySQL?

65,535 bytes, shared by every column in the row. With utf8mb4 a character can take 4 bytes, so one VARCHAR column tops out near 16,383 characters. Byte math: MySQL’s data type storage requirements page.

What is the difference between FLOAT and DOUBLE in MySQL?

FLOAT is single precision in 4 bytes and DOUBLE is double precision in 8 bytes. If you write FLOAT(p), MySQL picks FLOAT for p from 0 to 24 and DOUBLE for 25 to 53.

How do I change a column’s data type in MySQL?

Use ALTER TABLE customers MODIFY COLUMN zip_code VARCHAR(10) NOT NULL; and restate NOT NULL and DEFAULT, because MODIFY replaces the whole definition. In strict mode, rows that don’t fit make the ALTER fail.

Does MySQL have a UUID data type?

No. Store UUIDs in BINARY(16) with UUID_TO_BIN() and read them back with BIN_TO_UUID(), or in CHAR(36) if you want them readable. The full type list is in the MySQL 8.4 data types chapter.