SUBSTR From Position 0 Returns Text on SQLite and PostgreSQL and Nothing on MySQL
Code ported from a language that counts from 0 often ends up with SUBSTR(code, 0, 3). SQLite and PostgreSQL return the first two characters. MySQL and MariaDB return an empty string.
Positions start at 1. SQLite and PostgreSQL count position 0 as part of the requested length, while MySQL and MariaDB return nothing for a start of 0. Negative starts differ as well: SQLite, MySQL and MariaDB count them from the end, and PostgreSQL counts them before the first character.

What Each Engine Answered
The example
SELECT SUBSTR('abc', 0, 2);
gave these answers:
| Engine | Answer |
|---|---|
| SQLite 3.45.1 | 'a' |
| PostgreSQL 16.15 | 'a' |
| MySQL 8.0.46 | '' |
| MariaDB 10.11.14 | '' |
What to Write Instead
This form answers the same on every engine:
SELECT SUBSTR('abc', 1, 1);
| Engine | Answer |
|---|---|
| SQLite 3.45.1 | 'a' |
| PostgreSQL 16.15 | 'a' |
| MySQL 8.0.46 | 'a' |
| MariaDB 10.11.14 | 'a' |
Start at 1 or later. For the last n characters, SUBSTR(x, LENGTH(x) - n + 1) works on all four for ASCII text (LENGTH counts bytes on MySQL and MariaDB).
What BareSQL Portable Reports
Saved as example.sql, the example gets this from baresql lint --all:
$ baresql lint --all example.sql
example.sql:1:8: warning: SUBSTR('abc', 0, 2) is 'a' on SQLite and PostgreSQL and an empty string on MySQL and MariaDB. [substring-start]
SUBSTR('abc', 0, 2)
Start at 1 or later. For the last n characters, SUBSTR(x, LENGTH(x) - n + 1) works on all four for ASCII text (LENGTH counts bytes on MySQL and MariaDB).
1 problem: 1 warning.
baresql rules substring-start prints the same answers in the terminal, and the BareSQL Portable page lists every difference BareSQL knows.