MySQL SUBSTRING: extract part of a string
SUBSTRING is 1-indexed, not 0-indexed. Position 0 returns an empty string, silently.
Quick answer
Positions start at 1, not 0. SUBSTR and MID are aliases for the same function.
Syntax
Two forms:
There is also a standard-SQL form that does the same thing:
Both work. The comma form is more common in MySQL code; the FROM ... FOR form ports to PostgreSQL.
Positions start at 1
Coming from almost any programming language, this is the thing to internalize:
from_0 is empty. Not an error, not the first five characters. An empty string.
There is no character at position 0, so MySQL returns nothing, quietly. If your substring results are mysteriously blank, an off-by-one from 0-indexed thinking is the first thing to check. This is the single most common SUBSTRING bug.
Negative positions count from the end
-5 means "five characters from the end". This is genuinely useful and has no equivalent in LEFT/RIGHT when combined with a length:
Two characters, starting five from the end.
SUBSTR and MID
Identical functions with different names:
SUBSTRING is the standard name and the one to use. SUBSTR is shorter and also exists in PostgreSQL, Oracle, and SQLite. MID is a MySQL-ism inherited from spreadsheet conventions, and there is no reason to prefer it.
LEFT and RIGHT
For a prefix or suffix, these are clearer than SUBSTRING:
LEFT(str, n) is SUBSTRING(str, 1, n). RIGHT(str, n) is SUBSTRING(str, -n). Use them when that is what you mean.
SUBSTRING_INDEX: splitting on a delimiter
This is the function people actually want when they reach for SUBSTRING plus LOCATE:
A positive count takes from the left, a negative count from the right:
That is the whole email-splitting problem solved without arithmetic.
For a middle field, nest them:
Read it inside out: take the first three fields (a.b.c), then the last field of that (c).
An important edge case: if the delimiter is not present, SUBSTRING_INDEX returns the whole string, not NULL and not an empty string.
So splitting a malformed email gives you the malformed email back as the "domain". Validate before you split, or check afterwards.
Finding a position with LOCATE
When the position is not fixed, find it first:
LOCATE returns 0 when the substring is absent, which combines badly with SUBSTRING's position-0 behaviour:
That gives LOCATE = 0, so SUBSTRING(str, 1), so the entire string. A missing delimiter produces a plausible-looking wrong answer rather than an error. SUBSTRING_INDEX is safer for this, or guard it:
INSTR(str, substr) is the same as LOCATE with the arguments the other way round, which is a trap in itself. POSITION(substr IN str) is the standard-SQL spelling.
Characters, not bytes
SUBSTRING counts characters, and multi-byte characters count as one:
LENGTH returns bytes (5, because é is two bytes in UTF-8). CHAR_LENGTH returns characters (4). SUBSTRING works in characters, so it lines up with CHAR_LENGTH, not LENGTH.
Mixing them up produces truncation that only appears for non-ASCII data, which typically means it appears in production and not in your tests. Use CHAR_LENGTH unless you specifically want bytes.
SUBSTRING on a BLOB works in bytes, because a BLOB has no character set.
If CHAR_LENGTH disagrees, check your client charset
If CHAR_LENGTH('café') returns 5 rather than 4, MySQL is not wrong. Your connection is not speaking UTF-8, so the server received two separate latin1 characters rather than one multi-byte one.
This is easy to hit by accident. The official mysql:8 Docker image's command-line client defaults to latin1:
Fix the connection, not the query:
Or SET NAMES utf8mb4; at the start of a session.
HEX() settles the argument when you are unsure what is actually stored:
63 61 66 is caf, and C3A9 is é as two bytes. Five bytes, four characters. If the hex looks wrong, the data was mangled on the way in and no amount of SUBSTRING will fix it.
NULL handling
Any NULL argument gives NULL:
Both NULL. Wrap with COALESCE if you need a default:
Indexes and performance
Wrapping a column in SUBSTRING prevents MySQL using an index on it:
For prefix matching, LIKE with a trailing wildcard can use an index:
For suffix matching, the usual trick is to store the reversed value in a generated column and index that:
Now WHERE email_domain = 'example.com' is an index lookup. Generated columns need MySQL 5.7 or later, and STORED (rather than VIRTUAL) to be indexable in most cases.
Common problems
My substring is empty. You passed position 0. Positions start at 1.
My substring is off by one. Same cause, from a different direction. SUBSTRING(s, 1, 5) gives five characters starting at the first.
Splitting on a delimiter returns the whole string. The delimiter is not in the string. SUBSTRING_INDEX returns the input unchanged, and LOCATE returns 0.
Multi-byte characters are truncated. You used LENGTH where you needed CHAR_LENGTH.
The query got slow. SUBSTRING in WHERE disables the index. Use a generated column, or LIKE 'prefix%'.
Quick reference
| Task | Syntax |
|---|---|
| Characters from a position | SUBSTRING(str, pos, len) |
| From a position to the end | SUBSTRING(str, pos) |
| From the end | SUBSTRING(str, -n) |
| First n characters | LEFT(str, n) |
| Last n characters | RIGHT(str, n) |
| Before a delimiter | SUBSTRING_INDEX(str, '@', 1) |
| After a delimiter | SUBSTRING_INDEX(str, '@', -1) |
| Find a position | LOCATE(substr, str) (0 if absent) |
| Character count | CHAR_LENGTH(str) |
| Byte count | LENGTH(str) |
| Indexable extracted value | Generated STORED column + index |