cli/manuals/custom-types.md
Turso extends SQLite's STRICT table type system with user-defined custom types. Custom types let you define how values are encoded before storage and decoded when read, enforce domain constraints at the storage layer, attach operators, and provide defaults — all declared in pure SQL.
Custom types work only with STRICT tables (which are always enabled):
tursodb mydb.db
Without this flag, CREATE TYPE, DROP TYPE, the sqlite_turso_types virtual table, and all built-in custom types (date, varchar, numeric, etc.) are unavailable. PRAGMA list_types will only show the five base SQLite types (INTEGER, REAL, TEXT, BLOB, ANY).
CREATE TYPE type_name BASE base_type
ENCODE encode_expr
DECODE decode_expr
[OPERATOR 'op' [function_name] ...]
[DEFAULT default_expr];
text, integer, real, blob).value before writing to disk.value when reading from disk.function_name is omitted, the base type's built-in comparison is used (see Ordering below).The special identifier value refers to the input being encoded or decoded.
DROP TYPE type_name;
DROP TYPE IF EXISTS type_name;
A type cannot be dropped while any table has a column using it.
The simplest custom type — stores and reads values unchanged:
CREATE TYPE passthrough BASE text ENCODE value DECODE value;
CREATE TABLE t1(val passthrough) STRICT;
INSERT INTO t1 VALUES ('hello');
SELECT val FROM t1;
-- hello
Encode reverses the string for storage; decode reverses it back:
CREATE TYPE reversed BASE text
ENCODE string_reverse(value)
DECODE string_reverse(value);
CREATE TABLE t1(val reversed) STRICT;
INSERT INTO t1 VALUES ('hello');
SELECT val FROM t1;
-- hello (stored on disk as 'olleh')
Store monetary values as integers (cents) but present them as whole units:
CREATE TYPE cents BASE integer ENCODE value * 100 DECODE value / 100;
CREATE TABLE prices(amount cents) STRICT;
INSERT INTO prices VALUES (42);
SELECT amount FROM prices;
-- 42 (stored on disk as 4200)
Use json() as the encoder to reject malformed JSON at insert time:
CREATE TYPE jsontype BASE text ENCODE json(value) DECODE value;
CREATE TABLE t1(val jsontype) STRICT;
INSERT INTO t1 VALUES ('{"key": 1}'); -- OK
INSERT INTO t1 VALUES ('not json'); -- Error: malformed JSON
Custom types can overload SQL operators so expressions like val + val or val < 10 call user-defined functions:
CREATE TYPE uint BASE text
ENCODE test_uint_encode(value)
DECODE test_uint_decode(value)
OPERATOR '+' (uint) -> test_uint_add
OPERATOR '<' (uint) -> test_uint_lt
OPERATOR '=' (uint) -> test_uint_eq;
CREATE TABLE t1(val uint) STRICT;
INSERT INTO t1 VALUES (20);
INSERT INTO t1 VALUES (30);
SELECT val + val FROM t1;
-- 40
-- 60
SELECT val FROM t1 WHERE val < 25;
-- 20
Sorting and indexing always operate on encoded (on-disk) values, not decoded values. DECODE is purely a presentation layer — it controls how values appear in query results, but has no effect on sort order or index structure.
Custom types that support ordering must declare OPERATOR '<'. Without it, ORDER BY and CREATE INDEX on columns of that type are forbidden — attempting either produces a clear error.
OPERATOR '<' (Base Type Comparison)A naked OPERATOR '<' (no function name) tells Turso to compare encoded values using the base type's built-in comparison. This works correctly when the encoding preserves the desired sort order:
-- ENCODE value * 100 is monotonic: 10→100, 20→200, 30→300.
-- Sorting encoded integers preserves numeric order.
CREATE TYPE cents BASE integer
ENCODE value * 100
DECODE value / 100
OPERATOR '<';
CREATE TABLE prices(id INTEGER PRIMARY KEY, amount cents) STRICT;
INSERT INTO prices VALUES (1, 30), (2, 10), (3, 20);
SELECT amount FROM prices ORDER BY amount;
-- 10
-- 20
-- 30
If the encoding does not preserve order, the sort will reflect the encoded representation:
-- string_reverse is NOT monotonic: encoded text sorts differently than decoded.
-- Encoded: apple→elppa, banana→ananab, cherry→yrrehc.
-- Encoded text sort: ananab < elppa < yrrehc → display: banana, apple, cherry.
CREATE TYPE reversed BASE text
ENCODE string_reverse(value)
DECODE string_reverse(value)
OPERATOR '<';
CREATE TABLE t(id INTEGER PRIMARY KEY, val reversed) STRICT;
INSERT INTO t VALUES (1, 'apple'), (2, 'banana'), (3, 'cherry');
SELECT val FROM t ORDER BY val;
-- banana
-- apple
-- cherry
OPERATOR '<' with a Function (Custom Comparator)For types where the base type comparison on encoded values is not suitable, provide a custom comparator function. The comparator transforms encoded values before comparing:
-- numeric stores values as blobs; standard blob comparison is wrong.
-- numeric_lt knows how to compare encoded blobs numerically.
CREATE TYPE numeric(precision, scale) BASE blob
ENCODE numeric_encode(value, precision, scale)
DECODE numeric_decode(value)
OPERATOR '<' numeric_lt;
A comparator can also recover a desired sort order from a non-order-preserving encoding:
-- Same encoding as above, but the comparator reverses encoded values
-- before comparing, recovering alphabetical order.
CREATE TYPE reversed_alpha BASE text
ENCODE string_reverse(value)
DECODE string_reverse(value)
OPERATOR '<' string_reverse;
CREATE TABLE t(id INTEGER PRIMARY KEY, val reversed_alpha) STRICT;
INSERT INTO t VALUES (1, 'apple'), (2, 'banana'), (3, 'cherry');
SELECT val FROM t ORDER BY val;
-- apple
-- banana
-- cherry
Types without OPERATOR '<' cannot be used in ORDER BY or CREATE INDEX:
CREATE TYPE mytype BASE text ENCODE value DECODE value;
CREATE TABLE t(val mytype) STRICT;
SELECT val FROM t ORDER BY val;
-- Error: cannot ORDER BY column 'val' of type 'mytype': type does not declare OPERATOR '<'
CREATE INDEX idx ON t(val);
-- Error: cannot create index on column 'val' of type 'mytype': type does not declare OPERATOR '<'
Expression indexes that compute a regular value from a non-orderable column are still allowed:
CREATE INDEX idx ON t(length(val)); -- OK: length() returns an integer
The following built-in types declare OPERATOR '<' and support ORDER BY and indexing: date, time, timestamp, varchar, smallint, boolean, uuid, bytea, numeric.
Types without ordering support: json, jsonb, inet.
A default defined on the type applies to all columns of that type unless overridden:
CREATE TYPE uint BASE text
ENCODE test_uint_encode(value)
DECODE test_uint_decode(value)
DEFAULT 0;
CREATE TABLE t1(id INTEGER PRIMARY KEY, val uint) STRICT;
INSERT INTO t1(id) VALUES (1);
SELECT id, val FROM t1;
-- 1|0
A column definition can override the type's default:
CREATE TABLE t1(id INTEGER PRIMARY KEY, val uint DEFAULT 42) STRICT;
INSERT INTO t1(id) VALUES (1);
SELECT id, val FROM t1;
-- 1|42
The default can be an expression or function call:
CREATE TYPE reversed BASE text
ENCODE string_reverse(value)
DECODE string_reverse(value)
DEFAULT string_reverse('auto');
CREATE TABLE t1(id INTEGER PRIMARY KEY, val reversed) STRICT;
INSERT INTO t1(id) VALUES (1);
SELECT id, val FROM t1;
-- 1|otua
Use CASE ... ELSE RAISE(ABORT, ...) in the ENCODE expression to validate values and reject invalid input with a clear error message:
CREATE TYPE positive_int BASE integer
ENCODE CASE WHEN value > 0 THEN value
ELSE RAISE(ABORT, 'value must be positive') END
DECODE value;
CREATE TABLE t1(val positive_int) STRICT;
INSERT INTO t1 VALUES (42); -- OK
INSERT INTO t1 VALUES (-1); -- Error: value must be positive
This pattern is how built-in types like varchar and smallint enforce their constraints:
-- varchar checks length against the maxlen parameter
CREATE TYPE varchar(maxlen) BASE text
ENCODE CASE WHEN length(value) <= maxlen THEN value
ELSE RAISE(ABORT, 'value too long for varchar') END
DECODE value;
-- smallint checks the integer range
CREATE TYPE smallint BASE integer
ENCODE CASE WHEN value BETWEEN -32768 AND 32767 THEN value
ELSE RAISE(ABORT, 'integer out of range for smallint') END
DECODE value;
Types can declare parameters that are substituted into ENCODE/DECODE expressions. Parameters are specified in parentheses after the type name:
CREATE TYPE varchar(maxlen) BASE text
ENCODE CASE WHEN length(value) <= maxlen THEN value
ELSE RAISE(ABORT, 'value too long for varchar') END
DECODE value;
CREATE TABLE t1(name varchar(10)) STRICT;
INSERT INTO t1 VALUES ('hello'); -- OK (length 5 <= 10)
INSERT INTO t1 VALUES ('toolongname'); -- Error: value too long for varchar
When a column is declared as varchar(10), the parameter maxlen is replaced with 10 in the ENCODE expression.
Encoding runs before constraint checks (NOT NULL, type affinity). If an encode function returns NULL for a NOT NULL or PRIMARY KEY column, the insert is rejected:
CREATE TYPE my_uuid BASE text ENCODE uuid_blob(value) DECODE uuid_str(value);
CREATE TABLE t1(id my_uuid PRIMARY KEY, name TEXT) STRICT;
INSERT INTO t1 VALUES ('invalid-uuid', 'bad');
-- Error: NOT NULL constraint failed (uuid_blob returned NULL)
In STRICT tables, CHECK constraint comparisons are type-checked at table creation time. A custom type column cannot be directly compared to a raw literal — the types must match. Use CAST to convert literals to the custom type:
-- ERROR: type mismatch in CHECK constraint (cents vs INTEGER)
CREATE TABLE t1(amount cents CHECK(amount < 50)) STRICT;
-- OK: CAST converts the literal to cents, both sides have the same type
CREATE TABLE t1(amount cents CHECK(amount < CAST(50 AS cents))) STRICT;
This rule applies to all comparisons in STRICT tables, not just custom types:
-- ERROR: type mismatch (INTEGER vs TEXT)
CREATE TABLE t1(age INTEGER CHECK(age < 'old')) STRICT;
-- OK: same types
CREATE TABLE t1(age INTEGER CHECK(age >= 18)) STRICT;
Function calls in CHECK expressions also require CAST, because the return type cannot be determined at table creation time:
-- ERROR: cannot determine return type of length()
CREATE TABLE t1(name TEXT CHECK(length(name) < 10)) STRICT;
-- OK: CAST makes the type explicit
CREATE TABLE t1(name TEXT CHECK(CAST(length(name) AS INTEGER) < 10)) STRICT;
NULL values bypass encoding and decoding entirely:
CREATE TYPE uint BASE text
ENCODE test_uint_encode(value)
DECODE test_uint_decode(value);
CREATE TABLE t1(val uint) STRICT;
INSERT INTO t1 VALUES (NULL);
SELECT COALESCE(val, 'IS_NULL') FROM t1;
-- IS_NULL
You can cast values to a custom type, which applies the encode function:
CREATE TYPE reversed BASE text
ENCODE string_reverse(value)
DECODE string_reverse(value);
SELECT CAST('hello' AS reversed);
-- olleh
List all available types (built-in and custom) with their metadata:
PRAGMA list_types;
-- type | parent | encode | decode | default | operators
-- INTEGER | | | | |
-- REAL | | | | |
-- TEXT | | | | |
-- BLOB | | | | |
-- ANY | | | | |
-- uint | text | test_uint_encode(...) | test_uint_decode(...) | 0 | +(uint) -> test_uint_add
All types (built-in and user-defined) are available through the sqlite_turso_types virtual table:
SELECT name, sql FROM sqlite_turso_types;
Custom types work with ALTER TABLE ADD COLUMN:
CREATE TYPE uint BASE text
ENCODE test_uint_encode(value)
DECODE test_uint_decode(value);
CREATE TABLE t1(id INTEGER PRIMARY KEY) STRICT;
ALTER TABLE t1 ADD COLUMN val uint;
INSERT INTO t1 VALUES (1, 42);
SELECT id, val FROM t1;
-- 1|42
CREATE TYPE IF NOT EXISTS silently succeeds if the type already exists.value to reference the input.