Chapter 04 · SQLite’s Type System, Affinity, STRICT Tables, and Value Representation

NULL, BLOBs, Text Encoding, Collations, and Cross-Language Boundaries

Connect SQLite runtime values to application data contracts by handling NULL, binary data, text encoding, collations, Unicode expectations, and host-language bindings deliberately.

Beginner90–110 minutesCross-language data-contract labSQLite 3.53.4 baselineSTRICT requires SQLite 3.37.0+Last reviewed: August 2026

Learning outcomes

Types become operationally important at boundaries: JSON request to application, application object to driver, driver binding to SQLite, SQLite value back through the driver, and finally display in a terminal or UI. This lesson closes Chapter 4 by making those boundaries explicit, with special attention to NULL, binary data, text encoding, and text comparison rules.

01

Handle SQL NULL as its own storage class and distinguish it from absent application fields, empty text, and zero.

02

Create and bind BLOB values deliberately and decide when binary content belongs inside the database versus external storage.

03

Explain database text encoding separately from terminal/application display encoding.

04

Use SQLite’s built-in BINARY, NOCASE, and RTRIM collations without assuming full Unicode normalization or case folding.

05

Design a cross-language write contract that specifies host type, SQLite binding, storage class, domain validation, and NULL policy.

NULL is a runtime value with SQL semantics—not an empty placeholder

From the prerequisite SQL course, recall that NULL represents missing/unknown information and participates in three-valued logic. In SQLite’s runtime model, NULL is also one of the five storage classes. Drivers normally map a host-language null-like value to SQL NULL, but your API contract must still distinguish “field absent,” “field present with null,” and “field present with an empty/zero value.”

sql · NULL, empty text, and ordinary text are distinct
SELECT typeof(NULL) AS null_type,       NULL IS NULL   AS is_null_test,       NULL = NULL    AS equality_result;DROP TABLE IF EXISTS optional_note;CREATE TABLE optional_note (    note_id INTEGER PRIMARY KEY,    comment TEXT) STRICT;INSERT INTO optional_note(comment) VALUES (NULL), (''), ('checked');SELECT note_id, quote(comment), typeof(comment)FROM optional_noteORDER BY note_id;

NULL IS NULL is true; NULL = NULL produces SQL NULL rather than true. The three rows then preserve three states: NULL, empty TEXT, and non-empty TEXT. Decide which states your application allows instead of treating them as interchangeable.

BLOB means bytes that SQLite does not interpret as text

A BLOB is an arbitrary byte sequence. In SQL source, hexadecimal BLOB literals use the form x'...' with an even number of hexadecimal digits. In applications, use the driver’s binary binding type rather than hex-encoding bytes into a text string unless textual encoding is actually part of the contract.

sql · BLOB literal and inspection
SELECT x'0001FF' AS payload,       typeof(x'0001FF') AS payload_type,       length(x'0001FF') AS byte_count,       hex(x'0001FF') AS printable_hex;DROP TABLE IF EXISTS binary_sample;CREATE TABLE binary_sample (    sample_id INTEGER PRIMARY KEY,    mime_type TEXT,    payload   BLOB NOT NULL) STRICT;INSERT INTO binary_sample(mime_type, payload)VALUES ('application/octet-stream', x'0001FF');SELECT sample_id, mime_type, length(payload), hex(payload)FROM binary_sample;

The sample payload is three bytes. hex() is useful for diagnostics because it creates readable hexadecimal TEXT without changing what is stored in the BLOB column.

Internal versus external binary data is a workload decision

SQLite can store large BLOBs, but “can” is not the same as “always should.” Keeping an attachment inside the database can make transactions and backups self-contained. Keeping large media externally can simplify streaming, CDN/object-storage integration, or lifecycle policies. Performance depends on object size, page size, filesystem, access pattern, cache behavior, and application architecture; do not repeat a universal size threshold as folklore.

QuestionFavor storing BLOB in SQLite when…Favor external object/file when…
AtomicityMetadata and content must commit together simply.Application already has a robust external object transaction/reference workflow.
Backup/portabilityOne database artifact should contain the complete local state.Independent media lifecycle or remote object storage is intentional.
Access patternObjects are modest and usually accessed with related rows.Very large objects are streamed independently from database queries.
Security boundaryDatabase-level file protection/backup policy covers the content.A separate content service supplies its own authorization/encryption controls.
Operational toolingDatabase copy/restore should move content too.Database backups should remain small and content has a separate backup policy.

Chapter 16 will cover reliable backup/recovery semantics. Here, the design rule is to measure the actual workload and preserve a consistent reference if content is external.

Database text encoding is not terminal display encoding

SQLite databases use one database text encoding—UTF-8, UTF-16 little-endian, or UTF-16 big-endian—for stored TEXT representation in the database file. PRAGMA encoding reports the main database’s encoding. Once a database is created, attempting to change its encoding with the setting form does not rewrite the database.

sql · database encoding versus displayed text
PRAGMA encoding;SELECT 'Tehran تهران' AS multilingual_text,       typeof('Tehran تهران') AS storage_class,       length('Tehran تهران') AS character_count;

If the database is valid but a terminal prints mojibake, the problem may be the terminal/code-page/font/output path rather than the SQLite database encoding. Conversely, a UTF-8 terminal does not prove a database was created with UTF-8. Keep the layers separate: database representation, driver/API string representation, and display environment.

Do not use PRAGMA encoding as a casual migration tool

The setting form only affects creation of a new main database before its encoding is fixed. Existing databases are not converted by assigning a new PRAGMA value. Treat encoding changes as an explicit migration/export-import problem, not a runtime toggle.

Collation defines text comparison order, not Unicode normalization

A collating sequence tells SQLite how two TEXT values compare for ordering/equality contexts. SQLite has three built-in collations: BINARY, NOCASE, and RTRIM. The default is BINARY unless a column/expression specifies otherwise.

Built-in collationBehaviorImportant limit
BINARYByte-oriented comparison using SQLite’s built-in binary rule.Does not perform linguistic locale-aware comparison.
NOCASEFolds ASCII A–Z for case-insensitive comparison.It is not full Unicode case folding.
RTRIMLike binary comparison but ignores trailing ASCII space characters for comparison.It is not general whitespace normalization.
sql · built-in collation boundaries
SELECT 'A' = 'a' COLLATE NOCASE AS ascii_case_equal,       'É' = 'é' COLLATE NOCASE AS non_ascii_case_equal,       'abc ' = 'abc' COLLATE RTRIM AS trailing_space_equal;

The expected results are 1, 0, and 1. NOCASE’s built-in behavior is intentionally limited to ASCII case folding. If your application needs locale-aware ordering, full Unicode case folding, or normalization-equivalent matching, register/use an appropriate application/extension collation and test it as part of your deployment contract.

Unicode normalization is a separate problem from collation

Unicode can represent visually similar text using different code-point sequences. SQLite’s built-in collations do not promise to normalize those sequences into one canonical form. That means “looks identical in the UI” is not a sufficient uniqueness or equality rule for international text.

sql · normalization is not automatic
-- These two strings can render similarly but are different code-point sequences:SELECT hex('é') AS precomposed_utf8,       hex('é') AS decomposed_utf8,       'é' = 'é' AS binary_equal,       'é' = 'é' COLLATE NOCASE AS nocase_equal;

On a normal UTF-8 connection, the hex sequences differ and the equality tests are false. If canonical equivalence matters—for usernames, tags, search keys, or deduplication—choose a normalization policy in the application or a tested Unicode-aware extension/collation. Do not assume BINARY or NOCASE silently provides it.

Host-language adapters translate application types into SQLite values

Every language binding needs a mapping from host types to SQLite’s small set of value types. The exact API differs, but a common primitive mapping looks like the table below. Dates, decimals, UUID objects, enums, and custom classes generally need an explicit representation choice rather than a portable built-in SQLite type.

Application conceptTypical primitive bindingSQLite storage class
null / NoneNULL bindingNULL
bounded integer64-bit integer binding where in rangeINTEGER
floating-point numberdouble/float bindingREAL
Unicode stringtext bindingTEXT
byte array / bytesblob bindingBLOB
date/time objectApplication-defined TEXT/INTEGER/REAL conversionDepends on your contract
decimal objectApplication-defined exact representationOften INTEGER fixed-scale or canonical TEXT
UUID objectApplication-defined canonical TEXT or 16-byte BLOBTEXT or BLOB
python · verify primitive bindings at one application boundary
import sqlite3con = sqlite3.connect(':memory:')con.execute('CREATE TABLE boundary(value)')values = [None, 42, 3.25, 'PUMP-007', sqlite3.Binary(b'\x10\x20')]for value in values:    con.execute('INSERT INTO boundary(value) VALUES (?)', (value,))print(con.execute(    'SELECT group_concat(typeof(value), ",") FROM boundary').fetchone()[0])con.close()

The expected class sequence is null,integer,real,text,blob. A production adapter should also define overflow/range behavior, text error handling, and conversion policy for logical types. Do not convert everything to strings because it is convenient.

Dates and custom host objects need explicit adapters

A host language may offer date, decimal, UUID, or enum classes that SQLite itself does not know. Some drivers provide convenience adapters; their defaults can vary by language and version. Treat those conveniences as driver behavior, not as SQLite storage semantics.

Boundary decisionExample explicit policy
Datetime writeConvert application datetime to canonical UTC TEXT such as YYYY-MM-DDTHH:MM:SS.sssZ before binding.
Datetime readParse that exact TEXT contract into the host datetime type after retrieval.
Decimal writeQuantize using documented rounding rules, then bind fixed-scale minor units as INTEGER or canonical decimal TEXT.
UUID writeBind canonical string as TEXT or 16 raw bytes as BLOB consistently across all clients.
Enum writeBind documented TEXT labels or INTEGER codes and validate permitted members with CHECK/application logic.

Explicit conversion makes cross-language behavior testable. A Node.js client, Python client, Java client, and mobile client can all agree on the SQLite representation even though their host-language classes are different.

Data-contract exercise: define one API write end to end

Suppose an API receives a FieldNotes measurement. Before writing SQL, define what each field means at every boundary. The table below is a reusable design-review format.

FieldAPI/host meaningSQLite binding/storageDatabase ruleBoundary decision
device_codeNon-empty identifier stringTEXTNOT NULL; reference rule laterPreserve leading zeros/case policy explicitly.
observed_atUTC instantTEXTNOT NULLCanonical UTC ISO-style format; reject/normalize other forms at API boundary.
readingOptional sensor numberREAL or NULLNullableAbsent versus explicit null must be defined by API semantics.
is_validBooleanINTEGER 0/1NOT NULL + CHECKDo not bind arbitrary integers as “truthy.”
attachmentOptional bytesBLOB or NULLNullableSet application size/content-type policy; choose internal versus external storage deliberately.
trace_uuidRequest identifierTEXTNOT NULL + format validation layerOne canonical UUID form across languages.
service_minorOptional exact chargeINTEGER or NULLCHECK nonnegativeCurrency and scale must travel with or be fixed by the contract.
sql · verify the storage side of the contract
DROP TABLE IF EXISTS api_measurement;CREATE TABLE api_measurement (    measurement_id INTEGER PRIMARY KEY,    device_code    TEXT NOT NULL,    observed_at    TEXT NOT NULL,    reading        REAL,    is_valid       INTEGER NOT NULL CHECK (is_valid IN (0,1)),    attachment     BLOB,    trace_uuid     TEXT NOT NULL,    service_minor  INTEGER CHECK (service_minor IS NULL OR service_minor >= 0)) STRICT;INSERT INTO api_measurement(    device_code, observed_at, reading, is_valid,    attachment, trace_uuid, service_minor) VALUES (    'SENS-003', '2026-08-12T05:50:00Z', 21.75, 1,    x'89504E47', '550e8400-e29b-41d4-a716-446655440000', NULL);SELECT device_code,       typeof(observed_at), typeof(reading), typeof(is_valid),       typeof(attachment), typeof(trace_uuid), typeof(service_minor)FROM api_measurement;

Expected classes are TEXT, REAL, INTEGER, BLOB, TEXT, and NULL for the selected fields. The final exercise is to write the corresponding host-language binding types beside each column and add negative tests for wrong UUID form, invalid Boolean, oversize attachment, and ambiguous timestamp.

Cross-language checkpoint

Think in boundaries and contracts.

  1. Why is SQL NULL different from an empty string?
  2. What is the difference between database encoding and terminal display encoding?
  3. Does built-in NOCASE perform full Unicode case folding?
  4. Why should byte arrays be bound as BLOB rather than converted to hex TEXT by default?
  5. What five pieces should a cross-language field contract specify?
Review the answers

NULL is a distinct SQL/storage-class value; database encoding is the stored TEXT representation while terminal encoding is a display environment; built-in NOCASE folds only ASCII A–Z; binding bytes as BLOB preserves their binary meaning without inventing a textual transport encoding; and a useful field contract states host meaning/type, SQLite binding/storage, domain constraints, NULL/absence policy, and any normalization/unit/scale/format rules.

Failure patterns and safe corrections

FailureDiagnosisSafe correction
Empty string is used to mean “unknown.”NULL semantics and text semantics were collapsed.Define nullability and use NULL when the domain truly means missing/unknown.
Binary payload is base64/hex text everywhere.Transport encoding leaked into persistence without need.Bind BLOB for binary data; encode only at text-only boundaries that require it.
Unicode names compare unexpectedly under NOCASE.Built-in NOCASE is ASCII-focused and no normalization policy exists.Use explicit normalization and/or a tested Unicode-aware collation where requirements demand it.
Terminal output looks corrupted, so the DB is recreated.Display encoding was confused with database encoding.Verify PRAGMA encoding, driver behavior, and terminal settings before altering data.
Different services serialize dates/UUIDs differently.Host-language convenience adapters became implicit contracts.Specify one SQLite representation and test all clients against it.

Chapter 4 design review checklist

Reuse this checklist whenever a new field is added to the course database or to a production SQLite schema.

Review questionEvidence to collect
What is the logical type and valid domain?Requirements, units, enum members, precision/range, NULL semantics.
Which SQLite storage class should represent it?TEXT/INTEGER/REAL/BLOB/NULL choice with tradeoff rationale.
What declared type/affinity or STRICT type will the column use?DDL plus PRAGMA table_xinfo/table_list.
Can affinity convert the source representation unexpectedly?Insert tests plus typeof().
What does every host language bind?Driver-level contract tests for null/int/float/string/bytes/logical adapters.
What constraints belong in the database?NOT NULL, CHECK, UNIQUE, key and foreign-key decisions.
What normalization/encoding/collation is required?Unicode/case/date/UUID policy and comparison tests.
How will the representation migrate?Version compatibility and explicit migration plan rather than ad hoc casts.

Summary and bridge to Chapter 5

Chapter 4 replaced the “SQLite has no types” myth with a layered model. Runtime values have five storage classes. Ordinary columns derive affinity from declared type names and may convert values. STRICT tables add per-table rigid type contracts while preserving intentional heterogeneity through ANY. Logical types such as dates, Booleans, exact money, and UUIDs require explicit representation choices. Finally, drivers, encodings, collations, and NULL/BLOB semantics determine whether that contract survives across language boundaries.

Chapter 5 builds on this foundation by making invalid database states difficult to store: NOT NULL, UNIQUE, PRIMARY KEY, CHECK, DEFAULT, foreign keys, conflict handling, generated columns, and integrity auditing.

References

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only assets compatible with the Ethereum/ERC-20 network. Do not send TRC-20/TRON assets.