MariaDB behaves differently from MySQL, PostgreSQL, and SQLite in ways that regularly surprise developers. These are the most important differences — sourced from official MariaDB documentation.
15 gotchas
⚠
CONCAT Returns NULL If Any Argument Is NULL
MariaDB's CONCAT propagates NULL — if any argument is NULL the entire result is NULL.
✗ Anti-Pattern
SELECT CONCAT(first_name, ' ', last_name) FROM users;
-- Returns NULL for any row where either column is NULL
→ NULL (if any argument is NULL)
✓ Correct Approach
SELECT CONCAT_WS(' ', first_name, last_name) FROM users;
-- CONCAT_WS skips NULL arguments and joins the rest
→ Non-null parts joined by the separator
nullstringconcat
⚠
SYSDATE() vs NOW() — Different Points in Time
NOW() returns the timestamp when the current statement began; SYSDATE() returns the actual clock time at the moment it is called.
✗ Anti-Pattern
-- Assuming a slow stored procedure loop
SELECT SYSDATE(); -- returns wall-clock time, not statement start
-- Every iteration may return a different timestamp
→ Varies with actual execution time — unpredictable in loops or replication
✓ Correct Approach
SELECT NOW(); -- consistent within a single statement
-- Or: SELECT CURRENT_TIMESTAMP;
→ Same timestamp for every call within the same statement
datetimenowsysdatereplication
⚠
String Comparisons Are Case-Insensitive by Default
The default collation latin1_swedish_ci (or utf8mb4_general_ci) makes WHERE and UNIQUE comparisons case-insensitive.
✗ Anti-Pattern
SELECT * FROM users WHERE username = 'Admin';
-- Also matches rows where username is 'admin', 'ADMIN', etc.
→ Rows where username is 'admin', 'Admin', 'ADMIN', …
✓ Correct Approach
SELECT * FROM users WHERE username = BINARY 'Admin';
-- Or define the column with a _bin collation at table creation
→ Only rows where username is exactly 'Admin'
stringcollationcase-sensitivitycomparison
⚠
GROUP BY Allows Non-Aggregated Columns by Default
MariaDB does not enforce ONLY_FULL_GROUP_BY by default, so you can SELECT columns not in the GROUP BY without an aggregate function.
✗ Anti-Pattern
SELECT department, employee_name, COUNT(*)
FROM employees
GROUP BY department;
-- employee_name is not in GROUP BY and not aggregated
→ An arbitrary employee_name from each department group — result is non-deterministic
✓ Correct Approach
SELECT department, ANY_VALUE(employee_name), COUNT(*)
FROM employees
GROUP BY department;
-- ANY_VALUE() makes the intent explicit
→ One employee name per department (deterministically acknowledged as arbitrary)
group-byaggregationsql-mode
⚠
BOOLEAN Is Just TINYINT(1) — No Native Boolean Type
TRUE and FALSE are aliases for 1 and 0. Any TINYINT(1) column accepts values outside this range.
✗ Anti-Pattern
UPDATE flags SET active = 2; -- Stores fine
SELECT * FROM flags WHERE active = TRUE; -- Misses rows where active = 2
→ Rows where active = 2 are not returned despite being non-zero
✓ Correct Approach
SELECT * FROM flags WHERE active != 0; -- Truthy check
-- Or enforce the constraint: CHECK (active IN (0, 1))
→ All rows with a non-zero active value
booleantinyinttype-system
⚠
AUTO_INCREMENT Gaps After Rollback
When a transaction that inserts a row is rolled back, the AUTO_INCREMENT counter is not rolled back — the sequence value is consumed permanently.
✗ Anti-Pattern
START TRANSACTION;
INSERT INTO orders (customer_id) VALUES (42); -- Gets id = 101
ROLLBACK;
INSERT INTO orders (customer_id) VALUES (42); -- Gets id = 102, not 101
→ Gap at id = 101; next insert gets 102
✓ Correct Approach
-- Accept that AUTO_INCREMENT IDs are not guaranteed to be gapless.
-- If a gapless sequence is required, use a separate sequence table
-- with SELECT ... FOR UPDATE to manage allocation.
→ Gaps are normal and expected — do not rely on AUTO_INCREMENT for gapless sequences
auto-incrementtransactionrollbacksequence
⚠
TIMESTAMP Columns Auto-Update by Default
The first TIMESTAMP column in a table definition gets DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP applied automatically unless you specify otherwise.
✗ Anti-Pattern
CREATE TABLE events (
created_at TIMESTAMP, -- auto-gets DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
title VARCHAR(200)
);
UPDATE events SET title = 'New title' WHERE id = 1;
-- created_at is now overwritten with the current time
→ created_at changes on every UPDATE even though it was intended as a creation timestamp
✓ Correct Approach
CREATE TABLE events (
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- explicit, no ON UPDATE
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
title VARCHAR(200)
);
→ created_at is set once on insert and never changed; updated_at tracks modifications
timestampauto-updateddlgotcha
⚠
Multiple NULLs Are Allowed in UNIQUE Indexes
MariaDB (and standard SQL) allows multiple NULL values in a UNIQUE indexed column because NULL is not equal to NULL.
✗ Anti-Pattern
-- Expecting only one NULL allowed:
INSERT INTO accounts (email) VALUES (NULL); -- OK
INSERT INTO accounts (email) VALUES (NULL); -- Also OK — no duplicate violation
→ Both inserts succeed; two rows have NULL email despite the UNIQUE constraint
✓ Correct Approach
-- This is correct and expected behaviour per the SQL standard.
-- If you need at most one NULL, add a filtered unique index via application logic
-- or use a sentinel value and a CHECK constraint instead of NULL.
→ Multiple NULLs in a UNIQUE column are permitted and intentional
nulluniqueindexconstraint
⚠
Division With / Always Returns DOUBLE
The / operator performs floating-point division even between two integers. Use DIV for integer (truncating) division.
✗ Anti-Pattern
SELECT 7 / 2; -- Returns 3.5000, not 3
SELECT total_seconds / 60 FROM logs; -- Returns DOUBLE even if total_seconds is INT
→ 3.5000 — floating-point result, not an integer
✓ Correct Approach
SELECT 7 DIV 2; -- Returns 3 (integer truncation)
SELECT total_seconds DIV 60 FROM logs;
→ 3 — integer result with remainder discarded
arithmeticdivisionintegerdouble
⚠
ENUM Values Are Case-Insensitive on Storage
Inserting an ENUM value in a different case from its definition still succeeds and the value is normalised to the defined case.
✗ Anti-Pattern
CREATE TABLE users (status ENUM('Active', 'Inactive'));
INSERT INTO users (status) VALUES ('active'); -- Does not error
→ 'active' is silently stored as 'Active' — no error or warning
✓ Correct Approach
-- Understand that ENUM normalises case automatically.
-- If strict case enforcement matters, validate at the application layer
-- or use a CHECK constraint with a VARCHAR column instead.
→ Insert succeeds and stores 'Active' — be aware of the silent normalisation
enumcase-sensitivitystringstorage
⚠
TEXT Columns Cannot Have DEFAULT Values
Defining a DEFAULT on a TEXT, BLOB, or any large object column raises an error in MariaDB (prior to 10.2.1 it was silently ignored).
✗ Anti-Pattern
CREATE TABLE articles (
body TEXT DEFAULT '' -- ERROR in MariaDB
);
→ ERROR 1101 (42000): BLOB, TEXT, GEOMETRY or JSON column 'body' can't have a default value
✓ Correct Approach
CREATE TABLE articles (
body TEXT -- omit DEFAULT; handle NULL at application level
);
-- Or use VARCHAR(5000) DEFAULT '' for shorter content
→ Table created successfully; application sets default content on insert
textblobdefaultddllimitation
⚠
-> and ->> JSON Operators Require MariaDB 10.4.3+
The shorthand JSON path operators -> (extract as JSON) and ->> (extract as text) are only available from MariaDB 10.4.3 onwards.
✗ Anti-Pattern
-- On MariaDB < 10.4.3:
SELECT data->'$.name' FROM records; -- Syntax error
→ ERROR: You have an error in your SQL syntax
✓ Correct Approach
-- Compatible with all MariaDB versions that support JSON:
SELECT JSON_UNQUOTE(JSON_EXTRACT(data, '$.name')) FROM records;
-- Or on MariaDB 10.4.3+:
SELECT data->>'$.name' FROM records;
→ The extracted string value of the 'name' JSON field
jsonoperatorversioncompatibility
⚠
REGEXP Is Case-Insensitive by Default
MariaDB's REGEXP/RLIKE operator ignores case unless the BINARY keyword or a binary collation is used.
✗ Anti-Pattern
SELECT * FROM products WHERE name REGEXP 'widget';
-- Also matches 'Widget', 'WIDGET', 'wIdGeT'
→ Rows where name contains 'widget' in any combination of cases
✓ Correct Approach
SELECT * FROM products WHERE name REGEXP BINARY 'widget';
-- Only matches lowercase 'widget'
→ Only rows where name contains the exact lowercase string 'widget'
regexpregexcase-sensitivitystring
⚠
Implicit Type Coercion Can Cause Unexpected Comparisons
When comparing a string column to an integer, MariaDB silently casts the string to a number — leading characters that are not digits become 0.
✗ Anti-Pattern
SELECT * FROM codes WHERE code = 0;
-- code is VARCHAR; 'abc', 'xyz', '' all coerce to 0
-- Returns all rows where the code string starts with a non-digit character
→ Matches every row where code is a non-numeric string (coerced to 0)
✓ Correct Approach
SELECT * FROM codes WHERE code = '0'; -- compare as strings
-- Or explicitly cast: WHERE CAST(code AS SIGNED) = 0
→ Only rows where code is the literal string '0'
type-coercioncomparisonstringintegergotcha
⚠
LIMIT in Subqueries Has Restricted Usage
MariaDB does not allow LIMIT in certain subquery contexts, particularly in IN/ALL/ANY/SOME subqueries, which is a known MySQL/MariaDB restriction.
✗ Anti-Pattern
SELECT * FROM orders
WHERE customer_id IN (
SELECT id FROM customers ORDER BY signup_date DESC LIMIT 10
);
-- ERROR 1235: LIMIT not supported in IN subquery
→ ERROR 1235 (42000): This version of MariaDB doesn't yet support 'LIMIT & IN/ALL/ANY/SOME subquery'
✓ Correct Approach
SELECT * FROM orders
WHERE customer_id IN (
SELECT id FROM (
SELECT id FROM customers ORDER BY signup_date DESC LIMIT 10
) AS top_customers
);
→ Orders belonging to the 10 most recently signed-up customers
subquerylimitrestrictionin
Coming from MySQL or PostgreSQL?
MariaDB is largely MySQL-compatible but adds window functions, better JSON support, Oracle compatibility modes, and Aria storage engine. The Data Types page covers storage differences. Every function page includes a "vs MySQL" and "vs PostgreSQL" section.