BareSQL Portable

BareSQL Portable checks SQL that has to run on SQLite, PostgreSQL, MySQL and MariaDB. It knows 58 differences between those engines: 35 where at least one engine refuses the statement, 15 where every engine runs it and the answers differ, and 8 where the answers differ only for some data. Each one was run on all four engines, and each comes with a form that answers the same everywhere, where one exists.

It has three commands. baresql lint reads SQL files and points at the differences without connecting to anything. baresql compare runs a script on real engines and shows every answer that differs. baresql rules explains each difference with the answer every engine gave.

lint

baresql lint schema.sql queries.sql

Lint learns the column types from the CREATE TABLE statements it reads, so give it the schema along with the queries. That’s how it tells an integer division from a decimal one, or a text column compared with a number. This is its report on the sample shop’s queries:

shop.sql:6:8: warning: || joins text on SQLite and PostgreSQL. On MySQL and MariaDB it means OR, so 'a' || 'b' is 0. [pipes-concat]
    name || ' (' || stock || ')'
    Use CONCAT(a, b), which all four engines have (SQLite from version 3.44). CONCAT treats NULL differently on different engines; see concat-null.
shop.sql:7:8: warning: Dividing an integer by an integer drops the fraction on SQLite and PostgreSQL; MySQL and MariaDB keep it. [integer-division]
    SUM(stock) / COUNT(*)
    Multiply one side by 1.0 for the fraction and round it to the places you need (see decimal-division): ROUND(7 * 1.0 / 2, 2) is 3.5 on every engine. For a whole number on every engine, take the remainder off first: (7 - 7 % 2) / 2.
shop.sql:9:37: error: PostgreSQL has no = between a boolean and a number. [boolean-as-number]
    active = 1
    Compare and insert TRUE and FALSE (active = TRUE, or simply WHERE active). All four engines accept them.
shop.sql:10:8: error: A selected column that is neither grouped nor aggregated takes its value from any row of the group on SQLite and MariaDB; PostgreSQL and MySQL refuse the query. [ungrouped-column]
    name
    Add the column to GROUP BY, or say which value you want with an aggregate such as MAX(b).
shop.sql:10:14: error: A selected column that is neither grouped nor aggregated takes its value from any row of the group on SQLite and MariaDB; PostgreSQL and MySQL refuse the query. [ungrouped-column]
    price
    Add the column to GROUP BY, or say which value you want with an aggregate such as MAX(b).
shop.sql:12:8: error: IFNULL() works on SQLite, MySQL and MariaDB; PostgreSQL refuses it. [function-not-everywhere]
    IFNULL(stock, 0)
    Use COALESCE(a, b).

6 problems: 4 errors, 2 warnings. 4 notes not shown (differences that depend on the data); --all shows them.

Every problem has a severity. An error means at least one engine refuses the statement. A warning means every engine runs it and the answers differ. A note means the answers differ only for some data, such as text in mixed case or a column that holds NULL; notes are left out unless you pass --all.

The list of differences further down gives each rule’s usual severity. Lint reports the severity of what it actually found, so one rule can be an error in one statement and a note in another. Dividing by a column is a note, since the answers differ only when the column holds 0, but 1 / 0 written out is an error, because PostgreSQL refuses it.

Most projects use only two of the four engines. --engines sqlite,postgres keeps only the differences between those two, judged by the answers recorded for the exact construct lint found, so a MySQL-only problem drops out of a project that tests on SQLite and runs on PostgreSQL. A comment -- baresql:ignore integer-division on the line of a problem, or the line before it, silences that rule there.

compare

baresql compare --sqlite --postgres postgres://dev@localhost/scratch \
    --mysql mysql://dev@localhost:3306/ --mariadb mariadb://dev@localhost:3307/ shop.sql

compare runs the script statement by statement on every engine you name and prints the statements whose answers differ. Answers are compared by meaning: 3.5 equals 3.5000, true equals 1, dates match however they’re printed, and row order counts only under an ORDER BY. Text is compared exactly, so 'Mug' differs from 'mug', and text never equals a number. Here is the shop’s script on all four engines:

Compared on SQLite 3.45.1, PostgreSQL 16.15, MySQL 8.0.46, MariaDB 10.11.14.

shop.sql:5  SELECT name FROM products WHERE name = 'mug'
  0 rows on SQLite and PostgreSQL, 1 on MySQL and MariaDB
  SQLite and PostgreSQL: no rows
  MySQL and MariaDB: 'Mug'
  lint: text-compare-case

shop.sql:6  SELECT name || ' (' || stock || ')' FROM products ORDER BY id
  SQLite and PostgreSQL: 3 rows
      'Mug (10)'
      'mug lid (0)'
      NULL
  MySQL and MariaDB: 3 rows
      1
      0
      NULL
  MySQL warned: '|| as a synonym for OR' is deprecated and will be removed in a future release. Please use OR ins...; Truncated incorrect DOUBLE value: 'Mug'; Truncated incorrect DOUBLE value: ' (' (and 3 more)
  MariaDB warned: Truncated incorrect DECIMAL value: 'Mug'; Truncated incorrect DOUBLE value: ' ('; Truncated incorrect DECIMAL value: 'mug lid' (and 2 more)
  lint: pipes-concat

shop.sql:7  SELECT SUM(stock) / COUNT(*) FROM products
  SQLite and PostgreSQL: 3
  MySQL and MariaDB: 3.3333
  lint: integer-division, division-by-zero

shop.sql:8  SELECT name FROM products ORDER BY stock
  the same rows in a different order (NULL sorts differently, or rows that tie in ORDER BY came in another order)
  SQLite, MySQL and MariaDB: 3 rows
      'Teapot'
      'mug lid'
      'Mug'
  PostgreSQL: 3 rows
      'mug lid'
      'Mug'
      'Teapot'
  lint: null-order

shop.sql:9  SELECT COUNT(*) FROM products WHERE active = 1
  refused by PostgreSQL, answered by SQLite, MySQL and MariaDB
  SQLite, MySQL and MariaDB: 2
  PostgreSQL: error: operator does not exist: boolean = integer
  lint: boolean-as-number

shop.sql:10  SELECT name, price FROM products GROUP BY active
  refused by PostgreSQL and MySQL, answered by SQLite and MariaDB
  SQLite and MariaDB: 2 rows
      'mug lid', 2.25
      'Mug', 7.5
  PostgreSQL: error: column "products.name" must appear in the GROUP BY clause or be used in an aggregate function
  MySQL: error: Expression #1 of SELECT list is not in GROUP BY clause and contains nonaggregated column 'baresql_compare.products.name' which is not functionally dependent ...
  lint: ungrouped-column

shop.sql:12  SELECT IFNULL(stock, 0) FROM products ORDER BY id
  refused by PostgreSQL, answered by SQLite, MySQL and MariaDB
  SQLite, MySQL and MariaDB: 3 rows
      10
      0
      0
  PostgreSQL: error: function ifnull(integer, integer) does not exist
  lint: function-not-everywhere

11 statements: 2 answered alike, 7 differ, 2 skipped.
baresql rules <code> explains each lint code above.

Each engine works in a throwaway space: a transaction rolled back at the end on PostgreSQL, a scratch database dropped at the end on MySQL and MariaDB, and a temporary file on SQLite, run through sqlite3 -safe. compare skips the statements that would leave that space, such as transaction control, statements about databases and users, and SET GLOBAL. On MySQL and MariaDB it refuses any statement that names another database. It is no sandbox against a script written to get out, so point it at servers meant for testing.

rules

baresql rules lists every difference, and baresql rules CODE shows one: what differs and why, a minimal example with the answer each engine gave, the form that answers the same everywhere and what to write instead. --format json gives the whole catalog with its answers.

The 58 Differences

Each difference has its own post with the recorded answers, every form BareSQL checks and what lint prints for it.

Numbers

Text

Dates

NULL and Sorting

Queries

Inserts and Updates

Schemas

Syntax

Functions and Operators

How the Answers Were Recorded

Every example, rewrite and form in the catalog was run on SQLite 3.45.1, PostgreSQL 16.15, MySQL 8.0.46 and MariaDB 10.11.14, and the 1,212 answers are kept in the code repository. On every change, CI runs the whole catalog again on PostgreSQL 13 to 18, MySQL 8.0, 8.4 and 9.7, MariaDB 10.11, 11.4, 11.8 and 12.3, and the sqlite3 of its Linux and macOS machines, and fails when an answer changes. Where a version really answers differently, the catalog keeps that answer too, and lint’s messages say so (“MD5() works on PostgreSQL, MySQL (before 9.6) and MariaDB; SQLite refuses it”). Some builds of sqlite3 leave out its math functions or fold case beyond A to Z; the catalog says which answers assume the usual build.

Lint’s messages for functions, operators and types come from those runs, and so does each problem’s severity. The testing post has the details.

Limits

Lint reads tokens and recognizes the shapes of expressions. It doesn’t parse SQL fully, so it can miss a construct written in an unusual way. JSON counts only where it decides whether a statement runs at all, as with JSON_EXTRACT, ->> and the JSONB type. The rest of JSON, stored procedures and triggers are left out, since their syntax differs too much between engines to compare. Answers are recorded with each engine’s default settings: server modes such as MySQL’s sql_mode, and collations, change some of them.

Some differences have no rule yet: ALTER TABLE syntax, names that are reserved words on some engines (user, key, rank), aggregate FILTER, FROM DUAL, the digits of AVG over integers and ROUND of floating point halves among them. compare shows them when a script runs into one.