SQLite queries being translated to PostgreSQL in sqlTranslate

It started as one file next to the code. No server, no password, backups by cp. Two years later the side project has users, a second worker process, and one evening a log line that says database is locked. Time to move to Postgres?

Maybe. This article does the move for real: a 4.7 MB SQLite database with 4,985 users and 40,000 links, loaded into PostgreSQL 17.11 three ways, with every error counted. Moving the rows turned out to be the quick part. What takes the time is everything SQLite let you get away with.

First, check that you need to

SQLite's own guidance is blunter than most people expect. "Any site that gets fewer than 100K hits/day should work fine with SQLite", and that figure is called a conservative estimate. The limit that bites is a different one: SQLite "supports an unlimited number of simultaneous readers, but it will only allow one writer at any instant in time".

One writer at a time is less of a wall than it sounds, if the writers are allowed to wait their turn. We had four processes each run 1,000 single-row insert transactions against one file, on SQLite 3.50.4:

text
journal mode   busy_timeout   committed   "database is locked"   time     commits/s
DELETE         0 ms           1,062       2,938                    1.7 s      635
DELETE         5,000 ms       3,999           1                   12.0 s      334
WAL            0 ms             287       3,713                    0.3 s      876
WAL            5,000 ms       4,000           0                    1.7 s    2,287

Same file, same hardware, same code. With the default rollback journal and no timeout, three writes in four failed. With write-ahead logging and a five-second busy timeout, all 4,000 went through at about 2,300 commits a second. If database is locked is your whole reason for moving, try two lines first:

sql
PRAGMA journal_mode = WAL;      -- once; it's stored in the file
PRAGMA busy_timeout = 5000;     -- on every connection

The honest reasons to move are the ones on SQLite's own checklist. The data has to be reached over a network, because there's a second app server or a separate worker box (WAL needs every process on the same machine). Many writers at once that can't queue. Or you want what a server gives you: roles, replicas, point-in-time recovery, extensions, someone else running the backups. If one of those is you, read on.

The database

A link-saving app: users, links, tags, link_tags. The schema is what you'd write on day one:

sql
CREATE TABLE users (
  id         INTEGER PRIMARY KEY AUTOINCREMENT,
  email      TEXT NOT NULL UNIQUE,
  name       VARCHAR(30),
  is_admin   BOOLEAN NOT NULL DEFAULT 0,
  plan       TEXT DEFAULT "free",
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  last_seen  INTEGER                      -- unix time
);
CREATE TABLE links (
  id         INTEGER PRIMARY KEY AUTOINCREMENT,
  user_id    INTEGER NOT NULL REFERENCES users(id),
  url        TEXT NOT NULL,
  title      TEXT,
  clicks     INTEGER DEFAULT 0,
  archived   BOOLEAN DEFAULT 0,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

Every line of that is accepted by SQLite and nearly every line means something looser than it says. BOOLEAN, DATETIME and VARCHAR(30) aren't types in SQLite; they're hints. A column takes whatever you insert. Foreign keys are parsed and, unless each connection runs PRAGMA foreign_keys = ON, ignored. So we gave the data the history a real one has: a CSV import that wrote n/a into clicks, an admin script that stored the text true in is_admin, an import that wrote dates as 2025-04-02T15:49:30Z and another as 04/09/2025, names longer than 30 characters, and users deleted over the years whose links stayed behind.

Attempt one: dump it and pipe it in

text
sqlite3 linkboard.db .dump | psql lb

ERROR:  syntax error at or near "PRAGMA"
ERROR:  syntax error at or near "AUTOINCREMENT"
ERROR:  current transaction is aborted, commands ignored until end of transaction block
... 72,937 errors, 0 tables

The dump opens with a PRAGMA and wraps everything in one transaction, so the first error cancels all 72,959 lines. Delete the PRAGMA, the BEGIN/COMMIT and the word AUTOINCREMENT, run it again, and the next layer shows up: type "datetime" does not exist, so no tables, so 72,927 errors. Change DATETIME to timestamptz, the double-quoted "free" to single quotes and DEFAULT 0 to DEFAULT false, and the tables finally exist:

text
44,953  ERROR:  column "…" is of type boolean but expression is of type integer
27,938  ERROR:  insert or update on table "…" violates foreign key constraint "…"
    28  ERROR:  invalid input syntax for type integer: "…"

users: 4 rows    links: 0    link_tags: 0

Postgres won't take the integer 0 for a boolean, so nearly every user and link is refused, and then every link_tags row fails its foreign key because there are no links. The four users that did load are the four whose is_admin was the text true: the only rows where the dirty data happened to be valid Postgres. You can keep going this way with sed. Each round costs an evening and teaches you one more thing the file was hiding.

Attempt two: pgloader

pgloader reads the SQLite file directly, creates the tables with translated types, copies the rows, builds indexes, resets sequences and adds foreign keys. One command (a 3.6.10 development build, from the project's Docker image):

text
pgloader sqlite:///data/linkboard.db postgresql://postgres@localhost/lb

             table name     errors       rows      bytes      total time
-----------------------  ---------  ---------  ---------  --------------
           public.users          0       4985   355.1 kB          0.135s
           public.links         28      39972     2.9 MB          0.329s
       public.link_tags          0      27938   210.7 kB          0.116s
            public.tags          0          5     0.0 kB          0.010s
-----------------------  ---------  ---------  ---------  --------------
                    ...
        Reset Sequences          0          2                     0.048s
    Create Foreign Keys          2          1                     0.019s
-----------------------  ---------  ---------  ---------  --------------
      Total import time         28      72900     3.5 MB          0.735s

Under a second, and the exit status was 0. It would be easy to call that done. Read the summary again, then look at what's in Postgres.

28 links are gone. The rows with n/a or an empty string in clicks couldn't become bigint, so pgloader logged them (junk in string "n/a") and moved on. 39,972 of 40,000.

Two of the three foreign keys were not created. links.user_id has 111 rows pointing at users who no longer exist, so Postgres refused the constraint. And link_tags.link_id failed because some tags pointed at the 28 links that had just been dropped: one problem quietly made a second. The tables are there, the data is there, the constraints are not, and nothing will stop the next orphan.

The booleans are text. is_admin and archived arrived as text columns with default '0', holding '0', '1' and four 'true'. Any query that says WHERE is_admin now fails, and one that says WHERE is_admin = '1' misses four admins.

NOT NULL survived only on primary keys. users.email, links.user_id, links.url and tags.name all came across nullable in our run.

Dates were handled better than we feared. DATETIME text became timestamptz. The T...Z form was parsed, and 04/09/2025 was read as April 9, month first, which was right for this data and would be wrong for a file written in Europe. Text timestamps were taken as UTC, which matches what SQLite's CURRENT_TIMESTAMP stores; if your app wrote local time, every value is now off by your offset. last_seen stayed a bigint of unix seconds, because nothing in the schema says it's a time.

The rest was fine. VARCHAR(30) became text (the 45-character name came across whole; the limit was never real). Integer keys became bigint. Sequences were reset: the next user got id 5001. Index names look like idx_16633_sqlite_autoindex_users_1, which you may want to rename one day.

None of this is pgloader misbehaving. It did the sensible thing with each row and reported it. The trouble is in the file, and the fix belongs there too.

Clean it in SQLite, where it's still easy

Four queries find everything that tripped the load. Run them on a copy of the file.

sql
-- values that aren't the type the column claims
SELECT typeof(clicks), count(*) FROM links GROUP BY 1;        -- integer 39972, text 28
SELECT is_admin, typeof(is_admin), count(*) FROM users GROUP BY 1, 2;   -- 'true' text 4

-- dates that aren't YYYY-MM-DD HH:MM:SS
SELECT count(*) FROM users
WHERE created_at NOT GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9] [0-9][0-9]:[0-9][0-9]:[0-9][0-9]';   -- 12

-- rows that break a foreign key SQLite never enforced
PRAGMA foreign_key_check;                                       -- 111 rows, all links -> users

typeof() is the one to remember: it reports what is actually stored in each row, whatever the column was declared as. Run it on every column you think is a number. Then fix, and decide the one thing only you can decide, which is what an orphan means. We deleted ours; you might attach them to a placeholder user.

sql
UPDATE links SET clicks = NULL WHERE typeof(clicks) <> 'integer';                 -- 28
UPDATE users SET is_admin = 1 WHERE is_admin = 'true';                            -- 4
UPDATE users SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '')
 WHERE created_at LIKE '%T%Z';                                                    -- 7
UPDATE users SET created_at = substr(created_at, 7, 4) || '-' || substr(created_at, 1, 2)
                              || '-' || substr(created_at, 4, 2) || ' 00:00:00'
 WHERE created_at LIKE '__/__/____';                                              -- 5
DELETE FROM links WHERE user_id NOT IN (SELECT id FROM users);                    -- 111
DELETE FROM link_tags WHERE link_id NOT IN (SELECT id FROM links);                -- 70

Same pgloader command on the cleaned copy: 72,747 rows, no errors, three foreign keys out of three, half a second.

Finish the types in Postgres

The load gives you a faithful copy of a loosely typed schema. One transaction turns it into the schema you meant:

sql
BEGIN;
ALTER TABLE users
  ALTER COLUMN is_admin DROP DEFAULT,
  ALTER COLUMN is_admin TYPE boolean USING is_admin = '1',
  ALTER COLUMN is_admin SET DEFAULT false,
  ALTER COLUMN is_admin SET NOT NULL,
  ALTER COLUMN email SET NOT NULL,
  ALTER COLUMN last_seen TYPE timestamptz USING to_timestamp(last_seen);
ALTER TABLE links
  ALTER COLUMN archived DROP DEFAULT,
  ALTER COLUMN archived TYPE boolean USING archived = '1',
  ALTER COLUMN archived SET DEFAULT false,
  ALTER COLUMN user_id SET NOT NULL,
  ALTER COLUMN url SET NOT NULL;
ALTER TABLE tags ALTER COLUMN name SET NOT NULL;
COMMIT;

The old default has to be dropped before the type change, because '0'::text can't be cast to a boolean default on its own. After this, 12 admins are true, 4,973 are false, and last_seen for user 1 reads 2026-06-30 01:43:26+00 where it used to read 1782783806.

The queries change too

The data is across. The application still speaks SQLite. We ran the app's statements against both databases; these are the ones that gave a different answer or no answer.

text
statement                                       SQLite            PostgreSQL
title LIKE 'postgres%'                          19,690 rows       6,595 rows
ORDER BY last_seen LIMIT 3                      NULLs first       NULLs last
SELECT user_id, title, max(clicks) ... GROUP BY user_id
                                                one row per user  ERROR: must appear in GROUP BY
WHERE is_admin = 1                              12                ERROR: boolean = integer
WHERE plan = "pro"                              1,229             ERROR: column "pro" does not exist
SELECT 1 = '1'                                  0 (false)         t (true)
INSERT OR IGNORE INTO tags ...                  ok                ERROR: syntax error at or near "OR"
strftime('%Y-%m', created_at)                   2026-09           ERROR: function does not exist

The errors are the kind ones. You'll find them the first time the code runs. The first two rows are the dangerous ones, because nothing fails. SQLite's LIKE ignores case for ASCII letters and Postgres's doesn't, so a search box quietly returns a third of what it used to; the fix is ILIKE, which gave the same 19,690. And a "never seen" list sorted by last_seen used to start with the users who had never logged in and now starts with whoever logged in first in 2024, unless you write ORDER BY last_seen NULLS FIRST.

The GROUP BY one deserves a second look. SQLite lets you select a column that's neither grouped nor aggregated, and with max() it even promises the value comes from the row that had the maximum. Postgres wants you to say so: SELECT DISTINCT ON (user_id) user_id, title, clicks FROM links ORDER BY user_id, clicks DESC NULLS LAST returned the same rows.

For the mechanical part, paste the queries into sqlTranslate, SQLite to PostgreSQL:

sqlTranslate converting four SQLite statements to PostgreSQL: strftime becomes TO_CHAR, ifnull becomes COALESCE, group_concat becomes STRING_AGG; datetime('now') and INSERT OR IGNORE are unchanged
Four statements from the app. Three functions converted, a sort order preserved, two things left for you.

It turned strftime('%Y-%m', ...) into TO_CHAR(..., 'YYYY-MM'), ifnull into COALESCE and group_concat into STRING_AGG, and it added NULLS LAST to the descending sort so the order stays what SQLite gave. It left DATETIME('now', '-90 days') and INSERT OR IGNORE as they were, and both fail in Postgres: write now() - interval '90 days' and INSERT ... ON CONFLICT DO NOTHING. It also can't know your LIKE was relying on case-insensitivity. A translator handles syntax; behaviour is yours to check. We went through the same exercise from the other direction in MySQL to PostgreSQL: what a SQL translator fixes, and what you still fix by hand.

Cutover

Do the whole thing twice. The first run, on a copy, is where you find the 28 rows and write the fix script. The second is the real one: stop the app's writes, copy the file, run the fixes, load, run the ALTER script, compare, switch the connection string.

"Compare" means numbers from both sides: row counts per table, sum(clicks), count(*) WHERE is_admin, the newest created_at. Our counts were 4,985, 39,889, 27,868 and 5 on both. Then read twenty rows side by side, including one of the users with the strange dates. Pilotbase opens the SQLite file and the Postgres database as two connections in the same app, which makes that part quick.

Keep the SQLite file. It's a complete, consistent backup of the day you moved, in a format that will open in twenty years, and you can query it whenever someone asks what a value was before the migration. And if the CSV import that wrote n/a is still running somewhere, Postgres will now reject it loudly, which is half of why you came. For loading files like that one cleanly, see A messy CSV into Postgres without surprises.

Open the SQLite file and the new Postgres database side by side in Pilotbase to compare counts and rows before you switch. Desktop for Windows, macOS and Linux, or Docker on a server.

Get Pilotbase

Sources: SQLite documentation, Appropriate Uses For SQLite, Quirks, Caveats, and Gotchas, Write-Ahead Logging, Datatypes and foreign key support; pgloader documentation, SQLite to Postgres; PostgreSQL documentation, ALTER TABLE, pattern matching and INSERT ... ON CONFLICT. All counts, errors and timings are from our own runs: SQLite 3.50.4 (the dump with the 3.42 command-line shell), pgloader 3.6.10 and PostgreSQL 17.11 on one desktop machine; your timings will differ, the row counts won't.