SQLite SUBSTR: extract part of a string
substr is 1-indexed in SQLite. Position 0 behaves differently from MySQL, which matters when porting queries.
Quick answer
Positions start at 1. substring() is an alias for substr() and works identically.
Syntax
substring(X, Y, Z) is accepted as an alias. substr is the traditional SQLite spelling and the one you will see in most code.
Positions start at 1
The first character is at position 1, not 0.
Position 0 does not do what MySQL does
This is the one worth knowing, and it is a real portability trap:
SQLite returns 'Hell'. MySQL returns an empty string for the same call.
The reason is that SQLite treats position 0 as a real (if imaginary) position one before the first character, then counts 5 characters forward from there: positions 0, 1, 2, 3, 4. Position 0 holds nothing, so you get the four characters at 1 through 4.
MySQL instead decides there is no position 0 and returns nothing at all.
So substr(x, 0, n) silently returns n-1 characters in SQLite and zero characters in MySQL. Neither errors. If you are porting queries between the two, or writing something that runs on both, this produces a wrong answer that looks plausible in one engine and empty in the other.
Use position 1. There is no case where 0 is what you meant.
Negative positions count from the end
-5 means "start five characters from the end". With a length:
This is the cleanest way to take a suffix, since SQLite has no RIGHT() function.
SQLite has no LEFT or RIGHT
Unlike MySQL, there is no LEFT() or RIGHT(). Use substr:
Queries ported from MySQL will fail with:
SQLite has no SUBSTRING_INDEX
MySQL's delimiter-splitting function does not exist here:
Build it from instr and substr instead.
Everything before a delimiter:
Everything after a delimiter:
The instr-returns-0 trap
instr returns 0 when the substring is absent:
So when the delimiter is missing, the arithmetic quietly produces a negative length:
An empty string, with no error. So a malformed email produces an empty "local part" rather than a failure, and a row that should have been rejected flows onward looking merely blank. Guard it:
If you are doing this a lot, a generated column plus an index turns the extraction into a lookup:
Note VIRTUAL, not STORED. SQLite will not let you add a stored generated column to an existing table:
STORED is only available when you declare the column in the original CREATE TABLE. VIRTUAL computes the value on read, and unlike MySQL, SQLite lets you index a virtual generated column, so the index gives you the lookup speed without the stored bytes.
Generated columns need SQLite 3.31 or later.
Characters, not bytes
substr counts characters when the value is text:
length() on text returns characters (4). length() on a blob returns bytes (5, because é is two bytes in UTF-8). substr follows the same rule: characters for text, bytes for blobs.
This is more forgiving than MySQL, where LENGTH() always returns bytes and you need CHAR_LENGTH() for characters. But it means length() changes meaning based on the value's type, which SQLite decides per value rather than per column. A column declared TEXT can still hold a blob.
Use typeof(col) when a length looks wrong.
NULL handling
Any NULL argument gives NULL:
Both NULL. Use coalesce for a default:
Common problems
My substring is one character short. You passed position 0 and got n-1 characters. Use 1.
The same query returns different results in MySQL and SQLite. Almost certainly substr(x, 0, n). See above.
no such function: LEFT / RIGHT / SUBSTRING_INDEX. MySQL functions that SQLite does not have. Use substr with a negative position, or instr plus substr.
Splitting on a delimiter returns garbage. The delimiter is absent, so instr returned 0. Guard with CASE.
A length looks wrong for non-ASCII text. Check typeof(). If the value is a blob, length is counting bytes.
Quick reference
| Task | Syntax |
|---|---|
| Characters from a position | substr(x, pos, len) |
| From a position to the end | substr(x, pos) |
First n characters (LEFT) | substr(x, 1, n) |
Last n characters (RIGHT) | substr(x, -n) |
| Find a position | instr(x, 'y') (0 if absent) |
| Before a delimiter | substr(x, 1, instr(x, '@') - 1) |
| After a delimiter | substr(x, instr(x, '@') + 1) |
| Character count | length(x) on text |
| Byte count | length(CAST(x AS BLOB)) |
| Check the actual type | typeof(x) |