Imagine a recycling bin where you clearly put a label saying “Plastic Bottles Only”, but when someone drops a piece of paper in, it silently accepts it without a single word of protest?
This is the spine-chilling experience when developers meet the SQLite type system for the first time.
If you are used to the strict customs officer style of PostgreSQL (where incorrect types are directly denied entry), SQLite’s casualness might make you question your life.
Even more terrifyingly, when you want to modify a table structure, it will tell you:
“The table cannot be modified directly. Please build a new house, move the furniture over, and then blow up the old house.”
SQLite Under the Hood Has Only 5 Storage Classes
No matter what fancy type names you declare in CREATE TABLE (VARCHAR(255), BIGINT, DECIMAL), SQLite under the hood only recognizes these 5 storage classes:
| Storage Class | Description |
|---|---|
| NULL | Null value |
| INTEGER | Integer (automatically occupies 1 to 8 bytes depending on the magnitude) |
| REAL | Floating-point number (fixed 8 bytes) |
| TEXT | String (default UTF-8 encoding) |
| BLOB | Binary large object (stored exactly as entered) |
The types you set on columns are merely “suggestions” to SQLite, not “mandatory rules”.
This is called “Type Affinity”.
SQLitewill try to convert your data into the suggested type, but if it cannot, it will just stuff the original data in anyway without throwing any errors.
You can declare an age INTEGER column in SQLite and then insert the string 'forever eighteen'; it will accept it with pleasure.
Three Easiest Type Pitfalls to Step Into
Pitfall 1: No Native Boolean
SQLite has no boolean type. True and False can only be represented by the integers 1 and 0.
When you fetch data from SQLite using Node.js, you will get the number 1 or 0, not true or false.
If you directly perform a check like if (user.is_admin === true), it will never be true.
Pitfall 2: No Date/Time Type
SQLite has no date/time type. You can only store time as:
| Storage Method | Example | Pros & Cons |
|---|---|---|
| TEXT (ISO-8601 string) | '2026-05-19T18:00:00Z' |
Most recommended, high readability, seamless conversion when moving to PostgreSQL in the future |
| INTEGER (Unix Timestamp) | 1747656000 |
Small footprint, but not human-readable |
Never store dates in arbitrary custom formats like 2026/5/19 6:00 PM, otherwise data migration in the future will be a disaster.
Pitfall 3: Inserting a String into an Integer Column Won’t Throw an Error
In PostgreSQL, inserting a string into an INTEGER column throws an error immediately. But SQLite will only try to convert it silently, and if it fails, it accepts it as-is.
This means dirty data might quietly sneak into your database until one day your program crashes due to receiving an unexpected type, only then will you discover the issue.
Defensive Programming: Treating a Casual Database with a Strict Attitude
Faced with SQLite’s casualness, you must build strict defense mechanisms in Node.js development:
| Defense Level | Tool | Role |
|---|---|---|
| Compile-time Guard | TypeScript | Catch incorrect types at the code-writing stage |
| API Entry Validation | Zod | Validate incoming data strictly (ensure age is always a number) |
| Under-the-hood Type Cast | Prisma / Drizzle ORM | Automatically handle type differences between SQLite and PostgreSQL |
Moving the “gatekeeper of data validation” from the database layer to the
Node.jsapplication layer is a key strategy to leverage SQLite’s development speed while ensuring future scalability.
When using an ORM, as long as you declare type: 'boolean' in your code, the ORM automatically converts it to 1/0 when saving to SQLite, and converts it back to true/false when reading, perfectly masking the underlying type differences.
ALTER TABLE is Half-Baked: What Can and Cannot Be Modified
SQLite’s support for modifying table structures is very limited:
| Operation | Supported |
|---|---|
Add Column (ADD COLUMN) |
Yes |
Rename Column (RENAME COLUMN) |
Yes |
Drop Column (DROP COLUMN) |
Yes (in newer versions) |
Rename Table (RENAME TO) |
Yes |
| Change Column Type | No |
Add/Remove UNIQUE, NOT NULL Constraints |
No |
| Modify Primary Key | No |
| Modify Foreign Key | No |
Once you need to make any of the “unsupported” modifications,
SQLiterequires you to execute the “recreate and move” strategy.
The Four-Step Recreate & Move: SQLite’s Table Upgrade Way
Since it cannot be modified directly, the standard practice recommended by the official docs is building a new house, moving the furniture, blowing up the old house, and posting the new doorplate:
| Step | Action | Description |
|---|---|---|
| 1 | Create New Table | CREATE TABLE users_new (...) using the correct structure |
| 2 | Copy Data | INSERT INTO users_new SELECT ... FROM users |
| 3 | Drop Old Table | DROP TABLE users |
| 4 | Rename Table | ALTER TABLE users_new RENAME TO users |
These four steps must be executed in one breath; any power outage or application crash midway will result in data loss.
Ensuring Upgrades Don’t Lose Data: Two Safety Lines of Defense
Defense Line 1: Physical Defense, Copy the File Directly
SQLite is essentially just a file. Before executing any schema changes, simply make a copy of the .db file as a backup.
const fs = require('fs');
fs.copyFileSync('my_project.db', 'my_project_backup.db');
If things go wrong, replacing the file restores everything in an instant. This is an advantage that other large databases cannot offer.
Defense Line 2: Transaction Wrapper, The Database Time Machine
Wrap all migration steps inside a single Transaction; if any step fails, the entire process will automatically roll back as if nothing ever happened.
const Database = require('better-sqlite3');
const db = new Database('my_project.db');
const migrateData = db.transaction(() => {
db.prepare(`
CREATE TABLE users_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
age INTEGER NOT NULL DEFAULT 18
)
`).run();
db.prepare(`
INSERT INTO users_new (id, name, age)
SELECT id, name, COALESCE(age, 18) FROM users
`).run();
db.prepare('DROP TABLE users').run();
db.prepare('ALTER TABLE users_new RENAME TO users').run();
});
try {
migrateData();
console.log('Table upgraded successfully');
} catch (error) {
console.error('Upgrade failed, data safely restored:', error.message);
}
“Backup file + Transaction binding” is the airbag of your database migration.
Control the Casual SQLite with a Strict Attitude
Harness the casual
SQLitewith a strict application-layer architecture to enjoy its lightning-fast development speed while avoiding future technical debt.
SQLite’s type system is highly casual, and ALTER TABLE has many limitations.
However, as long as you perform TypeScript type checking + Zod validation + ORM abstraction on the Node.js side, paired with the safety strategy of physical backup + Transaction, you can safely enjoy the development efficiency brought by SQLite while paving the way to migrate to PostgreSQL in the future.