SQL Data Types
What types exist, which to choose for each use case, how types affect storage and performance, and how type mismatches cause silent bugs in calculations and comparisons
Quick answer
A column's data type decides what it can store and how it behaves: INTEGER for whole numbers, DECIMAL for exact amounts such as money, VARCHAR or TEXT for text, DATE and TIMESTAMP for time, BOOLEAN for true/false.
- Never store money as FLOAT; use DECIMAL(p, 2).
- Phone numbers and zip codes are text: they have leading zeros and no arithmetic.
- The wrong type causes silent bugs, such as text dates sorting alphabetically.
SELECT name, type
FROM pragma_table_info('orders');Result · 8 rows
| name | type |
|---|---|
| order_id | INTEGER |
| customer_id | INTEGER |
| store_id | VARCHAR |
| order_date | DATE |
| delivery_date | DATE |
| order_status | VARCHAR |
| payment_method | VARCHAR |
| total_amount | DECIMAL |
// Part 01
Why Data Types Matter More Than You Think
Every column in a SQL table has a data type. This is not just a technical detail — the type you choose for a column determines how the database stores it, how fast queries on it run, what operations are valid on it, and whether calculations produce correct results or silently wrong ones.
Three real consequences of wrong type choices that happen in production:
In FreshCart's schema, every type choice was deliberate. unit_price is DECIMAL(10,2) — not FLOAT — because money must be exact. customer_id is INTEGER — not VARCHAR — because IDs are numbers used in arithmetic and joins. order_date is DATE — not TIMESTAMP — because FreshCart only needs day precision. This lesson explains every choice like that.
// Part 02
The Five Type Families
SQL data types fall into five broad families. Every specific type belongs to one of these families, and understanding the families gives you a mental model for choosing types even when you encounter an unfamiliar database.
// Part 03
Numeric Types — Integers, Decimals, and Floats
Numeric types are the most consequential choice in database design. The difference between INTEGER, DECIMAL, and FLOAT is not just precision — it determines whether your financial calculations are correct.
Integer types — whole numbers
DECIMAL / NUMERIC — exact decimal arithmetic
DECIMAL(p, s) takes two parameters: p (precision) is the total number of significant digits, and s (scale) is the number of digits after the decimal point. DECIMAL(10, 2) stores up to 10 total digits with exactly 2 after the decimal — perfect for prices up to $99,999,999.99.
FLOAT / REAL / DOUBLE — approximate decimal arithmetic
FLOAT stores numbers in binary floating-point format — the same format used by computers for all floating-point arithmetic. It can represent an enormous range of values but cannot represent most decimal fractions exactly. The decimal 0.1 stored as a FLOAT is actually stored as the closest binary approximation: 0.1000000000000000055511151231257827021181583404541015625. Calculations compound these tiny errors into visible discrepancies.
// Part 04
Text Types — VARCHAR, CHAR, and TEXT
Text types store strings of characters. The choice between them affects storage efficiency, performance, and what operations are available.
VARCHAR vs TEXT — practical guidance
In PostgreSQL, VARCHAR and TEXT have identical performance — TEXT is not slower than VARCHAR. The difference is documentation: VARCHAR(100) tells any developer that this column should never exceed 100 characters, which acts as a soft constraint and communicates intent. TEXT has no upper bound. Use VARCHAR(n) when you have a reasonable maximum, TEXT when the length is genuinely unbounded (product descriptions, user reviews, log entries).
In MySQL, TEXT types are stored differently from VARCHAR — they cannot be fully indexed without a prefix and have different row-format implications. For MySQL, prefer VARCHAR(255) or VARCHAR(1000) over TEXT whenever a reasonable maximum exists.
String storage and collation
Every text column has a collation — a set of rules for comparing and sorting strings. Collation controls case sensitivity (is 'A' = 'a'?), accent sensitivity (is 'é' = 'e'?), and sort order. In PostgreSQL the default collation is determined at database creation. In MySQL the default is often utf8mb4_general_ci (case-insensitive). Mismatched collations between joined tables can cause join failures or incorrect results — a subtle bug that is hard to diagnose.
// Part 05
Date and Time Types — DATE, TIME, TIMESTAMP
Date and time types are among the most misused in SQL. Choosing the wrong one causes subtle bugs that only appear when edge cases hit — daylight saving time transitions, year boundaries, timezone changes.
DATE vs TIMESTAMP — the most important choice
Use DATE when only the calendar day matters and time is irrelevant. Order dates, birth dates, hire dates, expiry dates — these are pure dates. Using TIMESTAMP for these adds unnecessary complexity and opens timezone pitfalls.
Use TIMESTAMP WITH TIME ZONE (TIMESTAMPTZ) for any event that happens at a specific moment — when a user logged in, when a payment was made, when a notification was sent. TIMESTAMPTZ stores the moment in UTC and converts to local time on display. Without time zone, timestamps from different cities are ambiguous — 14:30:00 in Seattle and 14:30:00 in New York are completely different moments.
INTERVAL — storing durations
INTERVAL stores a duration rather than a point in time. It is used in date arithmetic for adding or subtracting time periods.
// Part 06
Boolean Type — TRUE, FALSE, and NULL
Boolean columns store true/false values. In PostgreSQL, BOOLEAN is a first-class type. In MySQL, it is stored as TINYINT(1) — 1 for true, 0 for false. Both support the same logical operations.
// Part 07
CAST and Type Conversion
Explicit type conversion is done with the CAST function or the PostgreSQL shorthand :: operator. This is essential when you need to convert between types in calculations, comparisons, or output formatting.
Common CAST use cases
Implicit vs explicit conversion
Some databases silently convert between compatible types — this is called implicit conversion. MySQL is more permissive about implicit conversion than PostgreSQL. Relying on implicit conversion creates code that works on one database but fails on another. Always use explicit CAST when you need a type conversion — it makes the intent clear and ensures portability.
// Part 08
Special Types — JSON, UUID, and Arrays
Modern databases support types beyond the basic five families. These are used in specific scenarios at production companies.
JSON and JSONB — storing structured documents
PostgreSQL supports JSON (text-stored JSON) and JSONB (binary-stored JSON, indexable and faster to query). JSONB is almost always preferred. MySQL supports a JSON type since version 5.7.
Stripe uses JSONB to store payment metadata — each payment instrument (card, ACH, digital wallet) has a completely different structure of additional fields. JSONB lets them store all of it in one column without creating dozens of nullable columns. Uber Eats uses JSONB for restaurant menu data — each item has different option structures.
UUID — universally unique identifiers
Arrays — storing multiple values in one column
// Part 09
Choosing the Right Type — The Decision Framework
When designing a column or reviewing a schema, run through these questions in order.
FreshCart schema — every type explained
| Column | Type chosen | Why this type |
|---|---|---|
| customer_id | INTEGER | Whole number, internal ID, will not exceed 2 billion customers |
| VARCHAR(255) | RFC 5321 max email length is 254 chars — 255 gives one byte headroom | |
| phone | VARCHAR(20) | Must store leading zeros and country codes — NOT an integer |
| joined_date | DATE | Only the calendar day matters — no time component needed |
| loyalty_tier | VARCHAR(20) | Short text with a finite set of values — CHECK constraint enforces allowed values |
| unit_price | DECIMAL(10,2) | Money — must be exact. 10 total digits, 2 decimal places (cent precision) |
| in_stock | BOOLEAN | Binary flag — either in stock or not. No other states needed |
| order_date | DATE | Calendar day of the order — no time needed |
| delivery_date | DATE | Calendar day of delivery — nullable (NULL = not yet delivered) |
| total_amount | DECIMAL(10,2) | Money — same reasoning as unit_price |
| salary | DECIMAL(10,2) | Money — monthly salary in dollars with cent precision |
| monthly_target | DECIMAL(12,2) | Larger amounts (store targets) — wider precision than individual prices |
// Part 10
Type Mismatches — Silent Bugs in Production
Type mismatches between columns and values in WHERE conditions, JOINs, and calculations are a major source of production bugs. They often do not cause errors — they cause silent wrong results or performance issues.
Zip code stored as INTEGER — leading zero lost
Date stored as VARCHAR — sorting breaks
JOIN on mismatched types — implicit cast or index miss
// Part 11
What This Looks Like at Work
You are a backend engineer at a Seattle fintech startup. A senior engineer asks you to review the schema for a new loan application table before it goes to production. You find three type-related issues that would have caused production bugs.
🎯 Pro Tip
Schema reviews for type correctness are one of the highest-value activities in backend engineering. A wrong type chosen at table creation is extremely costly to change in production — ALTER TABLE on a column type on a table with millions of rows locks the table, requires extensive data migration, and risks data loss. Getting it right before the first INSERT is worth hours of careful review.
// Part 12
Interview Prep — 5 Questions With Complete Answers
DECIMAL (also called NUMERIC) stores numbers as exact decimal digits — DECIMAL(10,2) stores exactly 10 significant digits with exactly 2 after the decimal point. Arithmetic on DECIMAL values produces exact results. 0.1 + 0.2 in DECIMAL is exactly 0.3. FLOAT stores numbers in binary floating-point format — the IEEE 754 standard used by all modern CPUs. Binary floating-point cannot represent most decimal fractions exactly. 0.1 stored as FLOAT is actually 0.1000000000000000055511151231... — a tiny error that compounds through arithmetic.
For money, FLOAT is never acceptable. Financial calculations require exact decimal arithmetic — $10.50 × 1,000,000 must be exactly $10,500,000.00, not $10,499,999.99 due to floating-point approximation. In a payment system processing millions of transactions, FLOAT errors accumulate into accounting discrepancies that violate regulatory requirements. The SEC and IRS require exact financial records. FLOAT violates this requirement at scale.
Always use DECIMAL for monetary values, prices, tax rates, and any financial calculation. The typical choices: DECIMAL(10,2) for amounts in dollars (up to $99,999,999.99), DECIMAL(5,4) for tax rates and percentages stored as decimals (0.0800 for 8% sales tax), DECIMAL(15,2) for very large institutional amounts. Use FLOAT only for scientific measurements, machine learning weights, and statistical values where exact decimal representation is neither possible nor required — the measurement itself has inherent imprecision that exceeds the floating-point error.
The functional difference between VARCHAR and TEXT varies by database. In PostgreSQL, VARCHAR and TEXT have identical storage and performance — there is no technical advantage to TEXT over VARCHAR or vice versa. The choice is entirely about documentation and constraints: VARCHAR(n) enforces a maximum length limit and communicates to any developer that the field should not exceed n characters. TEXT has no upper bound and makes no statement about expected length.
In MySQL, the distinction is more significant. VARCHAR(n) is stored inline with the row for values up to 65,535 bytes. TEXT types (TEXT, MEDIUMTEXT, LONGTEXT) are stored off-row as a reference, have different indexing limitations (cannot be fully indexed without a prefix), and have different performance characteristics for large values. For MySQL, prefer VARCHAR(n) for all fields where a reasonable maximum exists, and TEXT only for genuinely long content.
Professional guidance: use VARCHAR(n) whenever you can define a meaningful maximum — email VARCHAR(255), first_name VARCHAR(100), city VARCHAR(100), product_name VARCHAR(200). The n value should be based on the realistic maximum for the domain, not defaulted to 255 for everything. Use TEXT for genuinely unbounded fields: product descriptions, user reviews, blog content, log messages, any field where the author might reasonably write hundreds or thousands of words. The VARCHAR(n) limit serves as a soft validation and a schema communication tool — it tells the next developer what to expect from this column.
Phone numbers are not numeric values in the mathematical sense — they are identifiers that happen to use digits. Storing them as INTEGER or BIGINT causes three problems. First, leading zeros are lost. Many countries' local numbers start with a 0 dialling prefix — 020 7946 0958 in the UK stored as BIGINT becomes 2079460958, irreversibly losing the leading zero. Stored data can never be recovered to the original format.
Second, non-numeric characters cannot be stored. International format phone numbers include country codes (+1, +44), separators (-), and sometimes extensions (x204). BIGINT accepts none of these — any format beyond pure digits requires VARCHAR. Third, no arithmetic is ever performed on phone numbers — you never add two phone numbers together or calculate the average phone number. The entire reason to choose a numeric type is to enable arithmetic and range operations. Phone numbers need neither.
The correct type is VARCHAR(20) — long enough for any international format (+1-800-555-0199 is 15 chars, with headroom). This applies to any "number" that is really an identifier: EIN (12-3456789 — dash-formatted, no arithmetic), SSN (11 chars with dashes, no arithmetic), bank routing numbers, postal codes (some have letters — Canadian postal codes like K1A 0B1). The rule: if you would never multiply it, average it, or compare it with greater-than, store it as VARCHAR.
TIMESTAMP (TIMESTAMP WITHOUT TIME ZONE) stores a date and time value with no timezone information attached. The value is stored and retrieved exactly as entered — 2024-01-15 14:30:00 is stored and returned as-is. The database makes no attempt to convert it. If two users in different timezones insert the "same" timestamp, they might both insert 14:30:00 but mean completely different moments in absolute time (PST vs EST vs UTC).
TIMESTAMPTZ (TIMESTAMP WITH TIME ZONE) stores a specific moment in absolute time. The value is always stored internally as UTC. When you insert a timestamp with a timezone (or the database's configured timezone is used), it converts to UTC for storage. When you retrieve it, PostgreSQL converts from UTC to the current session's timezone. 2024-01-15 06:30:00-08:00 (PST) and 2024-01-15 14:30:00+00:00 (UTC) refer to the same moment and store identically.
Use TIMESTAMPTZ for all production application timestamps — created_at, updated_at, logged_at, processed_at. Any event that "happened at a specific moment" needs TIMESTAMPTZ to be unambiguous across timezones. Use TIMESTAMP only when the time has no timezone meaning — a scheduled broadcast at 20:00 every evening regardless of timezone, a business hours definition, a recurring calendar event. Most single-region startups operate in a single timezone (PST or EST) but use TIMESTAMPTZ anyway because it is the safer default and costs nothing extra. If you later add international users or move servers between regions, TIMESTAMP values become ambiguous while TIMESTAMPTZ remains correct.
CAST converts a value from one data type to another. The standard SQL syntax is CAST(expression AS target_type). PostgreSQL also supports the :: shorthand: expression::target_type. The conversion is explicit — you are telling the database exactly what type conversion you want, rather than relying on implicit conversion behaviour that may differ between databases.
The most important use cases for CAST: forcing decimal division when columns are integers — CAST(numerator AS DECIMAL) / denominator prevents integer truncation where 7/2 would return 3 instead of 3.5. Converting dates to strings for display — TO_CHAR or CAST(date_col AS VARCHAR) for concatenation with strings. Converting strings to dates when inserting or comparing with user input. Narrowing a type for a specific calculation — CAST(total_amount AS INTEGER) to drop cent precision when only whole dollars matter.
When not to use CAST: do not use CAST to work around a schema design error. If you are constantly casting a phone_number column from BIGINT to VARCHAR, fix the schema — change the column to VARCHAR. CAST is for legitimate temporary type coercion in calculations and output formatting, not a permanent workaround for wrong column types. Also avoid CAST on the column side of WHERE conditions because it prevents index usage — CAST(order_id AS VARCHAR) = '1007' disables the index on order_id. Rewrite the value side instead: order_id = CAST('1007' AS INTEGER) or simply order_id = 1007.
// Part 13
Errors You Will Hit — And Exactly Why They Happen
🎯 Key Takeaways
- ✓Data types are not just technical details — wrong choices cause silent bugs (FLOAT money rounding), data loss (INTEGER phone numbers), and schema migrations that lock production tables for hours.
- ✓Never store money as FLOAT or DOUBLE. Always use DECIMAL(p,2). Floating-point cannot represent decimal fractions exactly, and errors compound across millions of transactions into regulatory violations.
- ✓INTEGER for whole-number counts and IDs. BIGINT when values might exceed 2.1 billion. DECIMAL for any number with decimal precision. FLOAT only for scientific measurements with inherent imprecision.
- ✓VARCHAR(n) for variable-length text with a known maximum. CHAR(n) for fixed-length codes (country codes, currency codes, SSN). TEXT for unbounded content (descriptions, articles).
- ✓Phone numbers, SSN, EIN, routing numbers, postal codes — store as VARCHAR, never INTEGER. They have leading zeros, non-numeric characters, and require no arithmetic.
- ✓DATE for calendar days with no time component. TIMESTAMP for specific moments in a single timezone. TIMESTAMPTZ for specific moments across multiple timezones — use this for all production created_at and updated_at columns.
- ✓CAST(expression AS type) or expression::type (PostgreSQL) converts between types. Put CAST on the literal side of WHERE conditions, not the column side — CAST on a column prevents index usage.
- ✓BOOLEAN for binary flags (is_active, in_stock). For multi-state columns (order_status, approval_status), use VARCHAR with a CHECK constraint — not BOOLEAN.
- ✓Join columns must be the same type. Joining INTEGER customer_id to VARCHAR customer_id either throws an error (PostgreSQL) or silently disables indexes (MySQL), causing full table scans.
- ✓query information_schema.columns to inspect column types in any database. Always run a schema type audit before migrations and review type choices in code review before tables are created.
What comes next
In Lesson 18, you learn CREATE TABLE — how to define a table from scratch, set column types, add constraints, and design schemas that enforce data quality at the database level.
Lesson 18 → CREATE TABLEQuick check
Which type fits a US zip code such as 02134?
HELP US IMPROVE
How was this lesson?
Your note is shared with the Chaduvuko team for review. Please don’t include personal or sensitive information.
Spotted a mistake or something unclear? Suggest a fix on GitHub or email us. Select the text first and it is quoted for you.
Discussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.
