Syntax and behavior differences when moving to PostgreSQL
| Concept | MySQL | PostgreSQL | |
|---|---|---|---|
| Data Types | |||
| Integer | INT | INTEGER | |
| Large integer | BIGINT | BIGINT | |
| Small integer | TINYINT / SMALLINT | SMALLINT | |
| Unsigned integer | INT UNSIGNED | INTEGER with CHECK (col >= 0) | |
| Boolean | TINYINT(1) | BOOLEAN | |
| Variable-length string | VARCHAR(n) | VARCHAR(n) or TEXT | |
| Unlimited text | TEXT / MEDIUMTEXT / LONGTEXT | TEXT | |
| Date and time | DATETIME | TIMESTAMP | |
| Timezone-aware timestamp | TIMESTAMP (UTC-stored) | TIMESTAMPTZ | |
| Floating point | FLOAT / DOUBLE | REAL (4-byte) / DOUBLE PRECISION (8-byte) | |
| Exact decimal | DECIMAL(p,s) | NUMERIC(p,s) | |
| Binary data | BLOB / MEDIUMBLOB / LONGBLOB | BYTEA | |
| UUID | VARCHAR(36) | UUID | |
| Enumerated type | ENUM('a','b','c') | CREATE TYPE name AS ENUM ('a','b','c') | |
| JSON | JSON | JSONB | |
| Array | (not supported) | INTEGER[], TEXT[], etc. | |
| Auto-increment | |||
| Auto-incrementing primary key | INT AUTO_INCREMENT PRIMARY KEY | INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY | |
| Serial shorthand | INT AUTO_INCREMENT | SERIAL | |
| Get last inserted ID | LAST_INSERT_ID() | RETURNING id | |
| DDL | |||
| List tables | SHOW TABLES | \dt or SELECT tablename FROM pg_tables WHERE schemaname = 'public' | |
| Describe table structure | DESCRIBE tablename or SHOW COLUMNS FROM tablename | \d tablename or SELECT column_name, data_type FROM information_schema.columns WHERE table_name = 'name' | |
| List databases | SHOW DATABASES | \l or SELECT datname FROM pg_database | |
| Switch database | USE dbname | \c dbname (psql only — reconnect required) | |
| Identifier quoting | `backticks` | "double quotes" | |
| Conditional create | CREATE TABLE IF NOT EXISTS | CREATE TABLE IF NOT EXISTS | |
| Add column | ALTER TABLE t ADD COLUMN c INT | ALTER TABLE t ADD COLUMN c INTEGER | |
| Rename column | ALTER TABLE t CHANGE old_name new_name INT | ALTER TABLE t RENAME COLUMN old_name TO new_name | |
| Change column type | ALTER TABLE t MODIFY COLUMN c VARCHAR(255) | ALTER TABLE t ALTER COLUMN c TYPE VARCHAR(255) | |
| DML | |||
| Upsert (insert or update) | INSERT ... ON DUPLICATE KEY UPDATE | INSERT ... ON CONFLICT DO UPDATE SET ... | |
| Limit rows returned | SELECT ... LIMIT 10 | SELECT ... LIMIT 10 | |
| Limit with offset | LIMIT 10, 20 (offset, count) | LIMIT 20 OFFSET 10 | |
| Return values from INSERT/UPDATE/DELETE | LAST_INSERT_ID() (insert only) | RETURNING col1, col2 | |
| Truncate table | TRUNCATE TABLE t | TRUNCATE TABLE t or TRUNCATE TABLE t RESTART IDENTITY CASCADE | |
| String Functions | |||
| String concatenation | CONCAT(a, b) or (no || by default) | a || b or CONCAT(a, b) | |
| Null substitution | IFNULL(val, default) | COALESCE(val, default) | |
| String length | LENGTH() (bytes) / CHAR_LENGTH() (chars) | LENGTH() (chars) / OCTET_LENGTH() (bytes) | |
| Find substring position | LOCATE(substr, str) | POSITION(substr IN str) or STRPOS(str, substr) | |
| Extract substring | SUBSTR(str, pos, len) or SUBSTRING(str, pos, len) | SUBSTRING(str FROM pos FOR len) or SUBSTR(str, pos, len) | |
| Replace substring | REPLACE(str, from, to) | REPLACE(str, from, to) | |
| Upper/lower case | UPPER() / LOWER() | UPPER() / LOWER() | |
| Pad string | LPAD(str, len, pad) / RPAD(str, len, pad) | LPAD(str, len, pad) / RPAD(str, len, pad) | |
| Regex match | col REGEXP 'pattern' | col ~ 'pattern' (case-sensitive)
col ~* 'pattern' (case-insensitive)
col !~ 'pattern' (not match) | |
| Split string | No split — use SUBSTRING_INDEX() | STRING_TO_TABLE(str, delim) (PG16+)
or regexp_split_to_table(str, pattern) | |
| Date & Time | |||
| Current timestamp | NOW() or CURRENT_TIMESTAMP | NOW() or CURRENT_TIMESTAMP | |
| Current date | CURDATE() or CURRENT_DATE | CURRENT_DATE | |
| Add interval to date | DATE_ADD(date, INTERVAL 1 DAY)
DATE_ADD(date, INTERVAL 2 MONTH) | date + INTERVAL '1 day'
date + INTERVAL '2 months' | |
| Difference between dates | DATEDIFF(end, start) (days only)
TIMESTAMPDIFF(unit, start, end) | end::date - start::date (days)
EXTRACT(EPOCH FROM (end - start)) (seconds) | |
| Extract part of date | YEAR(d) / MONTH(d) / DAY(d)
DATE_PART('year', d) | EXTRACT(YEAR FROM d)
DATE_PART('year', d) | |
| Format date as string | DATE_FORMAT(d, '%Y-%m-%d') | TO_CHAR(d, 'YYYY-MM-DD') | |
| Parse string to date | STR_TO_DATE('15/01/2024', '%d/%m/%Y') | TO_DATE('15/01/2024', 'DD/MM/YYYY')
TO_TIMESTAMP('15/01/2024 10:30', 'DD/MM/YYYY HH24:MI') | |
| Unix timestamp (epoch) | UNIX_TIMESTAMP()
FROM_UNIXTIME(epoch) | EXTRACT(EPOCH FROM NOW())
TO_TIMESTAMP(epoch) | |
| Truncate to time unit | DATE(NOW()) (day only)
DATE_FORMAT(d, '%Y-%m-01') (month) | DATE_TRUNC('month', d)
DATE_TRUNC('hour', d) | |
| Conditional | |||
| IF expression | IF(condition, true_val, false_val) | CASE WHEN condition THEN true_val ELSE false_val END | |
| Return NULL if equal | NULLIF(a, b) | NULLIF(a, b) | |
| Greatest / Least of values | GREATEST(a, b, c) / LEAST(a, b, c) | GREATEST(a, b, c) / LEAST(a, b, c) | |
| Aggregates | |||
| Aggregate strings | GROUP_CONCAT(col SEPARATOR ', ') | STRING_AGG(col, ', ') | |
| Count distinct | COUNT(DISTINCT col) | COUNT(DISTINCT col) | |
| Arbitrary value from group | ANY_VALUE(col) | ANY_VALUE(col) (PG16+)
or MIN(col) / MAX(col) | |
| Aggregate into JSON array | JSON_ARRAYAGG(col) | JSON_AGG(col) or JSONB_AGG(col) | |
| JSON | |||
| Extract JSON value | JSON_EXTRACT(doc, '$.key')
doc->>'$.key' | doc->>'key' (text)
doc->'key' (jsonb) | |
| JSON contains check | JSON_CONTAINS(doc, '"value"', '$.key') | doc @> '{"key": "value"}' | |
| Set JSON value | JSON_SET(doc, '$.key', 'value') | jsonb_set(doc, '{key}', '"value"') | |
| Build JSON object | JSON_OBJECT('k', v) | jsonb_build_object('k', v)
JSON_OBJECT('k': v) (PG17+) | |
| Indexes | |||
| Full-text search index | FULLTEXT INDEX | CREATE INDEX ON t USING GIN (to_tsvector('english', col)) | |
| Partial index | (not supported) | CREATE INDEX ON t (col) WHERE condition | |
| Expression / function index | CREATE INDEX ON t ((LOWER(col))) | CREATE INDEX ON t (LOWER(col)) | |
| Non-blocking index build | (online DDL in some versions) | CREATE INDEX CONCURRENTLY ON t (col) | |
| Transactions | |||
| Start transaction | BEGIN or START TRANSACTION | BEGIN | |
| Savepoint | SAVEPOINT name | SAVEPOINT name
ROLLBACK TO SAVEPOINT name
RELEASE SAVEPOINT name | |
| Set isolation level | SET TRANSACTION ISOLATION LEVEL SERIALIZABLE | BEGIN ISOLATION LEVEL SERIALIZABLE | |
| Users & Auth | |||
| Create a user | CREATE USER 'alice'@'%' IDENTIFIED BY 'pass' | CREATE ROLE alice LOGIN PASSWORD 'pass' | |
| Grant table access | GRANT SELECT ON db.table TO 'alice'@'%' | GRANT SELECT ON table TO alice | |
| Grant all on schema | GRANT ALL ON db.* TO 'alice'@'%' | GRANT ALL ON ALL TABLES IN SCHEMA public TO alice
GRANT USAGE ON SCHEMA public TO alice | |
| Apply privilege changes | FLUSH PRIVILEGES | (automatic — no FLUSH needed) | |
| Gotchas | |||
| Case-sensitive string comparison | Case-insensitive by default (latin1_swedish_ci) | Case-sensitive by default | |
| Empty string vs NULL | '' ≠ NULL (but some contexts treat them similarly) | '' ≠ NULL (strictly different) | |
| String truncation | Silently truncates strings that exceed column width | Raises error (22001: string_data_right_truncation) | |
| Zero date | '0000-00-00' is a valid special value | Not valid — use NULL instead | |
| Integer division | 5 / 2 = 2 (integer division) | 5 / 2 = 2 (integer division) | |
| GROUP BY strictness | Allows selecting non-aggregated columns not in GROUP BY (ONLY_FULL_GROUP_BY optional) | Strict — all non-aggregated columns must be in GROUP BY | |
| Backslash in strings | Backslash is an escape character by default | Backslash is a literal character (standard_conforming_strings=on by default since PG9.1) | |
| Schemas vs databases | Schema and database are synonymous | Schemas are namespaces within a database. public is the default schema. | |