Three things are worth keeping apart: the type you write in CREATE TABLE, the type the row is
stored as, and the type a value has when a query hands it back.
The first two agree closely. The third collapses: every width of integer arrives as long, and
every kind of string arrives as string.
Integers
| Declare | Aliases | Stored as | Comes back as |
|---|---|---|---|
TINYINT |
INT8 |
1 byte, fixed | long |
UTINYINT |
UINT8, BYTE |
1 byte, fixed | long |
SMALLINT |
INT16, SHORT |
2 bytes, fixed | long |
USMALLINT |
UINT16, USHORT |
2 bytes, fixed | long |
INT |
INTEGER, INT32 |
1 to 5 bytes, variable | long |
UINT |
UINT32 |
1 to 5 bytes, variable | long |
BIGINT |
INT64, LONG |
1 to 10 bytes, variable | long |
UBIGINT |
UINT64, ULONG |
1 to 10 bytes, variable | long |
All of them read back as long.
The storage is worth a moment. The two narrow types are fixed width, so declaring TINYINT for
something that holds a percentage genuinely saves bytes. INT and BIGINT are variable width, so a
small value in a BIGINT column costs the same as the same value in an INT column, and choosing
INT over BIGINT to save space saves nothing. Choose them for the range you need instead, and
choose BIGINT for a key you expect to grow.
A value outside a column's declared range is refused rather than wrapped.
Real and exact numbers
| Declare | Aliases | Stored as | Comes back as |
|---|---|---|---|
FLOAT16 |
HALF |
2 bytes | double |
FLOAT |
FLOAT32, REAL |
4 bytes | double |
DOUBLE |
FLOAT64 |
8 bytes | double |
DECIMAL(p,s) |
NUMERIC, MONEY |
16 bytes | decimal |
Use DECIMAL for money and anything else where a rounding error would be a bug rather than a
nuisance.
Text
| Declare | Aliases | Stored as |
|---|---|---|
CHAR(n) |
NCHAR(n) |
Fixed, n bytes |
VARCHAR(n) |
NVARCHAR(n) |
Variable, length prefix and bytes |
TEXT |
NTEXT, STRING |
Variable, unbounded |
Everything is UTF-8, so the N prefixed names are aliases rather than a different encoding. All
three come back as string.
Binary
| Declare | Stored as | Comes back as |
|---|---|---|
BINARY(n) |
Fixed, n bytes | byte[] |
VARBINARY(n) |
Variable, bounded | byte[] |
BLOB |
Variable, unbounded | byte[] |
Dates and times
| Declare | Aliases | Stored as | Comes back as |
|---|---|---|---|
DATE |
DATEONLY |
4 bytes | DateOnly |
TIME |
TIMEONLY |
8 bytes | TimeOnly |
DATETIME |
TIMESTAMP, DATETIME2 |
8 bytes | DateTime |
DATETIMEOFFSET |
10 bytes | DateTimeOffset |
|
INTERVAL |
TIMESPAN, DURATION |
8 bytes | TimeSpan |
These map onto the .NET types of the same shape, so a DATE column is a DateOnly rather than a
DateTime at midnight.
Write a temporal literal with the type in front of it, which is what keeps its type through a round trip. A bare quoted string loses it.
INSERT INTO Events (On, At, Stamp, Offset) VALUES (
DATE '2026-08-11',
TIME '14:30:00',
TIMESTAMP '2026-08-11 14:30:00',
DATETIMEOFFSET '2026-08-11 14:30:00 +03:00'
);DATETIME works in place of TIMESTAMP.
The rest
| Declare | Aliases | Stored as | Comes back as |
|---|---|---|---|
BOOLEAN |
BOOL, BIT |
1 byte | bool |
GUID |
UUID, UNIQUEIDENTIFIER |
16 bytes | Guid |
JSON |
JSONB |
Text | JsonDocument |
ROWVERSION |
8 bytes | ulong |
ROWVERSION is maintained by the engine and increments whenever the row changes, which is what
optimistic concurrency checks against. EF Core's IsRowVersion() maps onto it.
JSON and JSONB are the same type here. Both store text, and the JSON functions parse it. See
functions.
An unknown type name becomes text
A type name the engine does not recognise is stored as a variable-length string rather than
refused. CREATE TABLE t (x INTEGERR) produces a text column, and nothing says so until a value
comes back the wrong shape.
Check the spelling of anything unusual, and prefer the canonical names in the left column of the tables above over the aliases, since a mistyped alias is exactly the case this hides.
ROWID
Every table has a ROWID, an internal 64-bit identifier the engine places rows by. It can be
selected like a column. An INTEGER PRIMARY KEY AUTOINCREMENT column is backed by the same counter,
which is why auto-increment works on a single-column key and not on a composite one.
Declared sizes are enforced
VARCHAR(n) and CHAR(n) mean what they say. A longer string is refused rather than truncated,
on insert and on update, because silently losing the end of a value is the outcome nobody can want.
DECIMAL(p,s) behaves as it does in PostgreSQL: a value with more decimals than the scale is
rounded to it, so 123.456 into a DECIMAL(5,2) is stored as 123.46 and that is not an
error. A value whose integer part does not fit in p - s digits is refused, since no rounding saves
it.
A value outside an integer column's range is refused rather than wrapped.
CAST
CAST converts a value and does not constrain it:
SELECT CAST(x AS SMALLINT) FROM t;The result is an integer, not an integer wrapped to two bytes. Casting 100000 to SMALLINT
returns 100000. SQLite behaves the same way. A cast expresses how you want the value read, and a
column type expresses what may be stored.
A cast that cannot read its input answers with a default rather than an error.
CAST('not a number' AS BIGINT) is 0, and CAST('abc' AS DATETIME) is the zero date. Check the
input first where that matters, with TYPEOF or a pattern.
That is deliberate, and the boundary is worth knowing: ALTER COLUMN ... TYPE does not behave
this way. A stored value that will not read as the new type is refused, naming the value, before
anything is written, because a rewrite of stored data that silently zeroes what it cannot parse is
a different thing from an expression that returns a default.
NULL
NULL is a value of its own, distinct from zero and from the empty string. Comparisons with it
give NULL rather than true or false, which is why WHERE x = NULL matches nothing and
WHERE x IS NULL is what you want.
The functions for working with it are COALESCE, IFNULL, NULLIF and IS [NOT] NULL; see
functions.
Where to go next
- DDL, declaring tables with these types
- Functions, converting between them
- Compatibility, where other databases differ