Help improve SovranCode?

Google Analytics can measure anonymous usage after you choose Allow. Essential account and progress features work either way.

SovranCode
SQL: Query, Model, and Analyze Data Data Types and NULL
This device
Course contentsData Types and NULL · 32 topics

Relational foundations

Databases, SQL, and the relational modelTables, rows, columns, and schemasData types and NULLPrimary keys, foreign keys, and relationships

Reading and filtering data

Your first SELECT queryConditions with WHERESorting, limiting, and distinct valuesText, dates, patterns, and conditional results

Reports, aggregation, and analytics

Aggregate functionsGROUP BY and HAVINGWindow functionsCommon table expressions

Combining related data

INNER, LEFT, RIGHT, and FULL joinsPlannedSelf joins and many-to-many dataPlannedSubqueries and correlated subqueriesPlannedUNION, INTERSECT, and EXCEPTPlanned

Designing reliable schemas

CREATE TABLE and schema designPlannedConstraints and data integrityPlannedNormalization and intentional denormalizationPlannedViews and materialized viewsPlanned

Writing and protecting data

INSERT, UPDATE, and DELETEPlannedUpserts and conflict handlingPlannedTransactions and savepointsPlannedIsolation, locks, and concurrencyPlanned

Performance and administration

Indexes and access pathsPlannedEXPLAIN and query plansPlannedQuery tuning patternsPlannedBackups, restores, and migrationsPlanned

Security and production workflow

Users, roles, and least privilegePlannedPreventing SQL injectionPlannedStored procedures, functions, and triggersPlannedProduction data projectPlanned
SQL: Query, Model, and Analyze Data12 complete · 20 planned

Relational foundations

Databases, SQL, and the relational modelTables, rows, columns, and schemasData types and NULLPrimary keys, foreign keys, and relationships

Reading and filtering data

Your first SELECT queryConditions with WHERESorting, limiting, and distinct valuesText, dates, patterns, and conditional results

Reports, aggregation, and analytics

Aggregate functionsGROUP BY and HAVINGWindow functionsCommon table expressions

Combining related data

INNER, LEFT, RIGHT, and FULL joinsPlannedSelf joins and many-to-many dataPlannedSubqueries and correlated subqueriesPlannedUNION, INTERSECT, and EXCEPTPlanned

Designing reliable schemas

CREATE TABLE and schema designPlannedConstraints and data integrityPlannedNormalization and intentional denormalizationPlannedViews and materialized viewsPlanned

Writing and protecting data

INSERT, UPDATE, and DELETEPlannedUpserts and conflict handlingPlannedTransactions and savepointsPlannedIsolation, locks, and concurrencyPlanned

Performance and administration

Indexes and access pathsPlannedEXPLAIN and query plansPlannedQuery tuning patternsPlannedBackups, restores, and migrationsPlanned

Security and production workflow

Users, roles, and least privilegePlannedPreventing SQL injectionPlannedStored procedures, functions, and triggersPlannedProduction data projectPlanned
PREVIOUS LESSONTables, Rows, Columns, and Schemas
NEXT LESSONPrimary Keys, Foreign Keys, and Relationships
Relational foundations · Lesson 03 120 min

Data Types and NULL

Data types and NULL are where SQL stops feeling like a spreadsheet. A column type is a promise about meaning, operations, and storage. NULL is the database’s marker for absence, unknown, or not applicable. This lesson teaches you to choose types deliberately, model money and dates safely, understand SQLite’s flexible typing, and write queries that handle missing values without lying.

What you will leave with

You will be able to choose practical text, integer, decimal, date/time, boolean, and binary representations; explain SQLite type affinity versus stricter engines; distinguish NULL from zero, false, and empty strings; use IS NULL, IS NOT NULL, NULLIF, and COALESCE; predict how WHERE handles unknown comparisons; and design required versus optional columns with honest constraints.

A data type is a promise about meaning

Value meaningDecide what kind of fact the column stores before picking a convenient-looking type name.
Valid operationsNumbers should calculate, dates should compare as time, and text should be searched or displayed as text.
MissingnessUse NULL only when absence is a real state your queries are prepared to handle.
Query behaviorRemember that comparisons with NULL become unknown, and WHERE keeps only true rows.

Start with type families, then verify your engine

Every database engine has its own exact type system, but most schema decisions start with a small set of value families. Text stores names, emails, codes, descriptions, and identifiers that are not meant for arithmetic. Integers store whole-number counts and stable identifiers. Decimal or exact numeric types store quantities that need fixed precision. Date/time types store moments, dates, or durations. Boolean values store yes/no states, even when the engine represents them internally as numbers. Binary types store bytes, not display text.

SQLite, which powers the browser runner, uses type affinity. A column declaration guides storage and conversion, but SQLite is more flexible than PostgreSQL or MySQL. That makes it excellent for learning SQL concepts in the browser, but it also means you must verify production behavior in the DBMS you deploy. A PostgreSQL integer column, a MySQL DECIMAL, and a SQLite NUMERIC column are related ideas, not identical contracts.

Type family map

Choose a type by the value’s meaning and operations, not by how the value happens to look in a CSV file.

TEXTWords and codes

Names, emails, labels, slugs, SKUs, JSON text, and values sorted or searched as characters.

INTEGERWhole numbers

Counts, cents, identifiers, positions, and quantities that should never have a fractional part.

DECIMALExact quantity

Money, measurements, and business numbers that need fixed precision in stricter engines.

DATE/TIMETime facts

Dates, timestamps, deadlines, periods, and values compared on a time axis.

BOOLEANTwo-state flags

Published, active, verified, archived, paid, or feature flags with a bounded set of states.

BINARYRaw bytes

Hashes, encrypted payloads, images, or files when the database is truly the right storage boundary.

Run SQLite affinity and inspect storage classes

SQLite stores values using storage classes such as integer, real, text, blob, and null. Column declarations give SQLite an affinity, or preference, for how values should be stored. The typeof() function lets you inspect what actually happened. This matters because the same literal can behave differently when placed into columns with different affinities.

SQL BROWSER RUNNER

Inspect SQLite type affinity

Store similar-looking values in different declared column types and inspect the storage class SQLite chose for each value.

CREATE TABLE imported_values (
  value_id INTEGER PRIMARY KEY,
  text_value TEXT,
  integer_value INTEGER,
  real_value REAL,
  numeric_value NUMERIC
);

INSERT INTO imported_values
  (value_id, text_value, integer_value, real_value, numeric_value)
VALUES
  (1, '42', 42, 42.0, 42),
  (2, '79.99', 79, 79.99, 79.99),
  (3, '2026-09-13', 20260913, 2026.0913, '2026-09-13');

SELECT
  value_id,
  text_value,
  typeof(text_value) AS text_storage,
  typeof(integer_value) AS integer_storage,
  typeof(real_value) AS real_storage,
  typeof(numeric_value) AS numeric_storage
FROM imported_values
ORDER BY value_id;
QUERY OUTPUT
Edit the query, predict the rows it will return, then run it.

Each run starts a fresh SQLite database in an isolated browser worker. Your edited SQL is saved on this device, but the database and its rows are discarded after the run.

Try this next: Change the numeric_value for row 1 to '42.5', rerun it, and explain why type inspection is better than guessing from the literal.

Do not overgeneralize from SQLite

SQLite is intentionally permissive. PostgreSQL, MySQL, SQL Server, and Oracle will reject or convert different values in different ways. The lesson to keep is not “types do not matter”; it is “the type contract belongs to the engine, so test the engine you use.”

Choose numeric types by the risk of being wrong

A numeric column is not just “anything with digits.” An identifier such as postal_code can contain leading zeroes and should often be text. A count of seats is an integer. A currency amount needs exact handling. A scientific measurement may tolerate approximate floating-point math. Choosing the wrong representation can create subtle defects: postal codes lose leading zeroes, prices gain rounding errors, and averages accidentally ignore missing facts.

For portable beginner projects, storing money as integer cents is often clear and reliable. Production billing systems also need currency, tax, rounding, refund, discount, and audit semantics. The schema should make those responsibilities explicit instead of pretending every price is just a display string.

SQL BROWSER RUNNER

Model money and boolean flags deliberately

Store prices as integer cents, keep a currency code, and model active state with a constrained SQLite integer flag.

CREATE TABLE subscriptions (
  subscription_id INTEGER PRIMARY KEY,
  plan_name TEXT NOT NULL,
  price_cents INTEGER NOT NULL CHECK (price_cents >= 0),
  currency TEXT NOT NULL DEFAULT 'USD' CHECK (length(currency) = 3),
  active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1))
);

INSERT INTO subscriptions
  (subscription_id, plan_name, price_cents, currency, active)
VALUES
  (1, 'Plus monthly', 1900, 'USD', 1),
  (2, 'Plus yearly', 19000, 'USD', 1),
  (3, 'Legacy trial', 0, 'USD', 0);

SELECT
  plan_name,
  '$' || printf('%.2f', price_cents / 100.0) AS display_price,
  CASE active WHEN 1 THEN 'active' ELSE 'inactive' END AS status
FROM subscriptions
ORDER BY price_cents;
QUERY OUTPUT
Edit the query, predict the rows it will return, then run it.

Each run starts a fresh SQLite database in an isolated browser worker. Your edited SQL is saved on this device, but the database and its rows are discarded after the run.

Try this next: Add a EUR plan, then try active = 2 and observe which rule protects the column.

Text, dates, and booleans need conventions

Text columns should store text facts, not every fact that arrived from a form as a string. Use text for names, emails, descriptions, tags, URLs, slugs, and domain codes. Use date/time representations when the value participates in time comparisons. In SQLite, ISO-like text timestamps such as YYYY-MM-DD HH:MM:SS sort usefully as text and can be passed to date/time functions. In PostgreSQL and other stricter systems, prefer native date and timestamp types.

SQLite does not have a separate boolean storage class. A common convention is INTEGER NOT NULL CHECK (flag IN (0, 1)). PostgreSQL has a real boolean type. MySQL has aliases and conventions. The point is the same: encode the intended states and reject impossible ones.

SQL BROWSER RUNNER

Store and query date-like values

Use sortable timestamp text in SQLite, allow an open-ended end time with NULL, and protect impossible ranges with a CHECK.

CREATE TABLE events (
  event_id INTEGER PRIMARY KEY,
  title TEXT NOT NULL,
  starts_at TEXT NOT NULL,
  ends_at TEXT,
  CHECK (ends_at IS NULL OR ends_at >= starts_at)
);

INSERT INTO events (event_id, title, starts_at, ends_at) VALUES
  (1, 'SQL study sprint', '2026-09-13 09:00:00', '2026-09-13 10:30:00'),
  (2, 'Open office hours', '2026-09-14 15:00:00', NULL),
  (3, 'Schema review', '2026-09-15 11:00:00', '2026-09-15 12:00:00');

SELECT
  title,
  date(starts_at) AS event_day,
  time(starts_at) AS start_time,
  COALESCE(time(ends_at), 'not scheduled') AS end_time
FROM events
WHERE starts_at >= '2026-09-14'
ORDER BY starts_at;
QUERY OUTPUT
Edit the query, predict the rows it will return, then run it.

Each run starts a fresh SQLite database in an isolated browser worker. Your edited SQL is saved on this device, but the database and its rows are discarded after the run.

Try this next: Try inserting an event whose ends_at is earlier than starts_at, then explain why NULL is allowed but an impossible range is not.

Text

Best for character data and domain codes. Do not use it as a dumping ground for numbers that need math.

Date and time

Use sortable, engine-appropriate representations so filtering and ordering match time rather than display style.

Boolean states

Keep allowed values bounded. A flag with three real states may need an enum-like status instead.

NULL is not zero, false, or an empty string

NULL means the value is absent, unknown, or not applicable. It is a marker, not a normal value. Zero means a known number with value 0. An empty string means a known text value with length 0. False means a known negative boolean state. These differences matter because users, reports, constraints, and application behavior depend on them.

Consider shipped_at. A row with NULL shipped time may mean the order has not shipped or the time is unknown. A row with an empty string usually means bad imported text that should be cleaned. A row with a real date means the shipment event is known. The query below shows how IS NULL and = '' answer different questions.

SQL BROWSER RUNNER

Compare NULL with an empty string

Run two filters against shipment data and see why missing values and blank text are not the same data state.

CREATE TABLE shipments (
  shipment_id INTEGER PRIMARY KEY,
  order_number TEXT NOT NULL UNIQUE,
  shipped_at TEXT,
  tracking_code TEXT
);

INSERT INTO shipments
  (shipment_id, order_number, shipped_at, tracking_code)
VALUES
  (1, 'SO-1001', '2026-09-10', 'TRK-001'),
  (2, 'SO-1002', NULL, NULL),
  (3, 'SO-1003', '', ''),
  (4, 'SO-1004', '2026-09-12', NULL);

SELECT order_number, shipped_at, tracking_code
FROM shipments
WHERE shipped_at IS NULL
ORDER BY shipment_id;

SELECT order_number, shipped_at, tracking_code
FROM shipments
WHERE shipped_at = ''
ORDER BY shipment_id;
QUERY OUTPUT
Edit the query, predict the rows it will return, then run it.

Each run starts a fresh SQLite database in an isolated browser worker. Your edited SQL is saved on this device, but the database and its rows are discarded after the run.

Try this next: Add a WHERE tracking_code IS NULL query, then compare it with tracking_code = ''. Which rows tell you the import needs cleanup?

NULL DECISION GUIDEAsk what absence means before writing the column
  1. 01
    Required for every valid row?

    Use NOT NULL and require writers to supply or inherit a real value.

    Example

    A course title, product SKU, or account email usually cannot be absent.

  2. 02
    One honest default?

    Use DEFAULT when omission has a safe, truthful meaning.

    Example

    A new draft can default to unpublished; an unknown country should not default silently.

  3. 03
    Unknown or not applicable?

    Allow NULL and make reports handle that state explicitly.

    Example

    An unresolved ticket has no resolved time yet.

  4. 04
    Known blank text?

    Use an empty string only when blank text is a meaningful value, not a sloppy placeholder.

    Example

    A blank optional display subtitle may be valid; a blank email is usually not.

Three-valued logic changes WHERE results

SQL comparisons do not return only true or false. When a comparison involves NULL, the result is usually unknown. WHERE keeps rows where the condition is true. It filters out false and unknown. That is why score >= 70 excludes both a score of 0 and a missing score, even though those rows mean different things.

SQL BROWSER RUNNER

Predict true, false, and unknown filters

Compare raw comparison output with WHERE behavior so NULL no longer feels random.

CREATE TABLE quiz_attempts (
  attempt_id INTEGER PRIMARY KEY,
  student TEXT NOT NULL,
  score INTEGER CHECK (score BETWEEN 0 AND 100)
);

INSERT INTO quiz_attempts (attempt_id, student, score) VALUES
  (1, 'Ada', 92),
  (2, 'Grace', NULL),
  (3, 'Linus', 0),
  (4, 'Nora', 74);

SELECT student, score, score >= 70 AS comparison_result
FROM quiz_attempts
ORDER BY attempt_id;

SELECT student, score
FROM quiz_attempts
WHERE score >= 70
ORDER BY student;

SELECT student, score
FROM quiz_attempts
WHERE score IS NULL OR score < 70
ORDER BY student;
QUERY OUTPUT
Edit the query, predict the rows it will return, then run it.

Each run starts a fresh SQLite database in an isolated browser worker. Your edited SQL is saved on this device, but the database and its rows are discarded after the run.

Try this next: Add WHERE score < 70 and then WHERE score IS NOT NULL AND score < 70. Explain whether the result changes and why the second version documents intent better.

True

score >= 70 is true for known passing scores, so WHERE keeps those rows.

False

score >= 70 is false for known lower scores, so WHERE removes those rows.

Unknown

NULL >= 70 is unknown, not false. WHERE still removes it unless you explicitly include IS NULL.

Use COALESCE and aggregates with intention

COALESCE returns the first non-NULL expression. It is useful for display fallbacks, grouping labels, and optional values in reports. Use it carefully: replacing a missing numeric value with zero can be honest for “no discount applied,” but dishonest for “score not graded yet.” A fallback in the SELECT list changes the result presentation; it does not repair stored data.

Aggregates also treat NULL deliberately. COUNT(*) counts rows. COUNT(column) counts known values in that column. SUM and AVG ignore NULL inputs. That behavior is useful, but it means every report should state whether it is counting records, known values, or missing values.

SQL BROWSER RUNNER

Count rows, known values, and missing values

Use COUNT(*) and COUNT(column) to separate total tickets from tickets that have a resolved time.

CREATE TABLE support_tickets (
  ticket_id INTEGER PRIMARY KEY,
  title TEXT NOT NULL,
  resolved_minutes INTEGER
);

INSERT INTO support_tickets (ticket_id, title, resolved_minutes) VALUES
  (1, 'Password reset', 12),
  (2, 'Billing question', NULL),
  (3, 'Export request', 35),
  (4, 'Login bug', NULL);

SELECT
  COUNT(*) AS total_tickets,
  COUNT(resolved_minutes) AS resolved_tickets,
  COUNT(*) - COUNT(resolved_minutes) AS unresolved_tickets,
  ROUND(AVG(resolved_minutes), 1) AS avg_minutes_for_resolved
FROM support_tickets;
QUERY OUTPUT
Edit the query, predict the rows it will return, then run it.

Each run starts a fresh SQLite database in an isolated browser worker. Your edited SQL is saved on this device, but the database and its rows are discarded after the run.

Try this next: Add MIN and MAX for resolved_minutes, then add a percentage of resolved tickets using total rows as the denominator.

Display fallback is not data cleanup

COALESCE(nickname, name) can be excellent in a report. It does not mean every missing nickname should be overwritten with the legal name. Keep result shaping separate from durable data changes.

Make required, optional, default, and unknown explicit

A column should allow NULL only when the model has a real missing state. Required identifiers, names, and state fields often deserve NOT NULL. Optional timestamps, completion dates, cancellation reasons, and external references may be nullable because the event has not happened or the value is not applicable. Defaults are powerful, but they should represent a true default rather than hiding incomplete writes.

Before you create a column, ask four questions: What does the value mean? Which operations must work correctly? Can the value be absent in a valid row? If it is absent, is that unknown, not applicable, not happened yet, or bad imported data? Good schemas are full of these small honest decisions.

price = '79.99'

Number stored as display text

Text prices sort and calculate like strings unless converted. Store a numeric representation and format at the edge.

postal_code = 02110

Code treated as arithmetic

Not every digit string is a number. Codes can have leading zeroes and should often be text.

shipped_at = ''

Blank string as missing date

Use NULL for missing date facts, then clean imports that use blanks as placeholders.

active = maybe

Unbounded state

A boolean or status column should reject states your application cannot reason about.

Independent lab: repair a messy import

Real data often arrives as text, even when the facts are not text. Your job is to turn the imported members into a cleaner result without pretending bad data is good data. Convert blank strings into NULL, cast numeric ages only when they look numeric, map yes/no marketing flags to 1 and 0, and keep uncertain facts as NULL for later review.

SQL BROWSER RUNNER

Clean text imports into typed facts

Use NULLIF, CASE, and CAST to convert imported text into a cleaner report while preserving unknown values honestly.

CREATE TABLE raw_members (
  member_id INTEGER PRIMARY KEY,
  email TEXT,
  joined_at TEXT,
  age_text TEXT,
  marketing_opt_in TEXT
);

INSERT INTO raw_members
  (member_id, email, joined_at, age_text, marketing_opt_in)
VALUES
  (1, 'ada@example.test', '2026-09-01', '34', 'yes'),
  (2, '', '2026-09-03', '', 'no'),
  (3, 'linus@example.test', '', '29', ''),
  (4, 'grace@example.test', '2026-09-05', 'not supplied', 'yes');

SELECT
  member_id,
  NULLIF(email, '') AS email,
  NULLIF(joined_at, '') AS joined_at,
  CASE
    WHEN age_text GLOB '[0-9]*' AND age_text <> '' THEN CAST(age_text AS INTEGER)
    ELSE NULL
  END AS age,
  CASE marketing_opt_in
    WHEN 'yes' THEN 1
    WHEN 'no' THEN 0
    ELSE NULL
  END AS marketing_opt_in
FROM raw_members
ORDER BY member_id;
QUERY OUTPUT
Edit the query, predict the rows it will return, then run it.

Each run starts a fresh SQLite database in an isolated browser worker. Your edited SQL is saved on this device, but the database and its rows are discarded after the run.

Try this next: Add a computed review_reason column that labels missing email, missing joined_at, invalid age, and unknown opt-in values.

Lab review criteria

Your cleaned result should never convert an unknown age to zero, should never treat a blank email as valid, should preserve missing dates as NULL, should map known yes/no flags consistently, and should make suspicious rows easy to review.

Lesson review

Types are part of the schema’s meaning, not decoration. Choose text, integer, exact numeric, date/time, boolean, and binary representations based on the fact being modeled and the operations required. SQLite’s affinity model is flexible, so inspect behavior while learning and verify stricter production engines separately. NULL is a missingness marker, distinct from zero, false, and empty strings. Comparisons with NULL produce unknown, WHERE keeps only true rows, and aggregates count rows versus known values differently. The best schemas make absence honest and visible.

  • I can explain why a column type is a contract about meaning and operations.
  • I can choose sensible representations for money, dates, booleans, identifiers, and display text.
  • I can distinguish NULL, zero, false, and an empty string in both schema design and query filters.
  • I can use IS NULL, IS NOT NULL, NULLIF, and COALESCE deliberately.
  • I can predict why a nullable value may disappear from a WHERE result.
  • I can write reports that separate total rows, known values, and missing values.
KNOWLEDGE CHECK

Check your type and NULL reasoning

Answer all eight questions, then use the explanations to tighten the parts of SQL that most often surprise beginners.

01What is the main job of a SQL data type?
02Why is storing money as integer cents often safer than storing 79.99 in a floating-point column?
03Which statement about NULL is correct?
04Which condition finds rows where shipped_at is missing?
05Why can WHERE score <> 0 exclude rows where score is NULL?
06What is COALESCE useful for in a SELECT list?
07Which aggregate counts only known values in email?
08When should a column usually be declared NOT NULL?
PREVIOUS LESSONTables, Rows, Columns, and Schemas
NEXT LESSONPrimary Keys, Foreign Keys, and Relationships
ON THIS PAGEData Types and NULLLesson mapType familiesSQLite affinityNumeric valuesText, dates, booleansNULL meaningThree-valued logicCOALESCE and aggregatesSchema choicesIndependent labLesson reviewKnowledge check
Course contents