Dev Encyclopedia
ArticlesToolsContactAbout

Get notified when new content drops

No spam. Just new articles, tools, and updates straight to your inbox.

Dev Encyclopedia

A reference for builders

Dev.to
Discord
WhatsApp Channel
daily.dev
Hashnode
X

Content

  • Articles
  • Tools
  • About
  • Contact

Connect

  • support@devencyclopedia.com
  • RSS Feed

Legal

  • Privacy Policy
  • Terms of Service
  • Disclaimer

© 2026 Dev Encyclopedia

Back to top ↑
  1. Home
  2. /Blog
  3. /PostgreSQL for MySQL Developers: The Complete Guide (Syntax, Queries, and Key Differences)
databases21 min read

PostgreSQL for MySQL Developers: The Complete Guide (Syntax, Queries, and Key Differences)

Coming from MySQL? Learn PostgreSQL's syntax, data types, and query differences with real examples, so you can start writing Postgres with confidence.

Zeeshan Tofiq
Zeeshan Tofiq
September 18, 2026
On this page

On this page

  • Where MySQL Habits Break in PostgreSQL
  • The Mental Model Shift
  • Syntax, Quoting, and Identifier Case
  • Data Types: The Translation Table
  • Query Syntax You Will Write Every Day
  • JSON and JSONB: Where Postgres Pulls Ahead
  • Indexing: GIN, GiST, and Partial Indexes
  • Transactions, DDL, and the Concurrency Model
  • Things That Will Bite You
  • When to Actually Pick PostgreSQL vs MySQL
  • Quick Reference Cheat Sheet
  • Frequently Asked Questions

Where MySQL Habits Break in PostgreSQL

If you have spent years writing MySQL and just joined a project running PostgreSQL, you already know SQL. You know SELECT, JOIN, GROUP BY, and how to write a decent index. What trips people up is not SQL itself. It is the dozen small Postgres-specific habits that do not match what MySQL taught you, and the fact that some of them fail loudly while others fail quietly and hand you wrong data.

This guide is written for that exact situation: you know the fundamentals, you need the deltas. Every section maps a MySQL construct you already use to its PostgreSQL equivalent, then explains where the two genuinely diverge rather than just look different.

If your SQL fundamentals are rusty before you start, our SQL interview questions reference covers the shared ground both databases build on. Everything below assumes that ground is solid.

💡 TL;DR

Postgres is stricter about types, lowercases unquoted identifiers, uses SERIAL or identity columns instead of AUTO_INCREMENT, || instead of CONCAT(), and ON CONFLICT instead of ON DUPLICATE KEY UPDATE. It gives you JSONB, arrays, partial indexes, and transactional DDL in return. Budget time for a connection pooler before you go to production.

Side-by-side terminal screenshot comparing a MySQL client session on the left and a psql session on the right, both running a DESCRIBE or table inspection command against the same users table, showing the different output formatting of each client
The same users table inspected in the MySQL client and in psql. Same data, different client conventions.

The Mental Model Shift

The biggest adjustment is not syntax, it is how strict Postgres is compared to MySQL. MySQL will often let you get away with things: inserting a string into a numeric column, comparing values of mismatched types, running DDL inside a transaction that silently commits parts of it. Postgres rejects most of that outright. Try to insert 'abc' into an integer column and it throws an error immediately instead of coercing or truncating.

This is not Postgres being difficult. It follows the SQL standard more closely. MySQL's permissiveness made sense in the nineties for ease of adoption, but it also means bugs can hide in your data for years without anyone noticing. Postgres would rather break your query today than corrupt your data quietly.

The second core difference is concurrency. Both databases use MVCC (Multi-Version Concurrency Control) so readers and writers do not block each other, but the implementations diverge in ways you will eventually feel in production.

MySQL (InnoDB)PostgreSQL
Old row versionsKept in a separate undo log, purged automatically by a background threadKept in the table itself as dead tuples until VACUUM reclaims them
Failure mode under churnUndo log growth and history list length on long transactionsTable and index bloat, plus transaction ID wraparound risk if autovacuum falls behind
Type handlingCoerces where it can, warns or truncates depending on SQL modeRejects mismatched types at parse or execution time
DDL in a transactionImplicitly commits, cannot be rolled backFully transactional, rolls back cleanly
Connection costThread per connection, cheap to scale into the thousandsOS process per connection, needs a pooler past a few hundred

That first row is the one to internalise. When a Postgres table "feels" slower over time even though your query volume has not changed, dead tuples and a lagging autovacuum are almost always the reason. It is the single most common surprise for people arriving from InnoDB, and it has no MySQL equivalent to reason from.

MySQL is not free of the problem, it just moves it. Long-running transactions there inflate the undo log and the history list instead. The difference is where you look when things degrade, and what command you reach for.

Syntax, Quoting, and Identifier Case

MySQL uses backticks for identifiers containing spaces or reserved words. Postgres uses double quotes, which is the SQL standard.

sql — identifier quoting
-- MySQL
SELECT `user id`, `order status` FROM `orders`;

-- PostgreSQL
SELECT "user id", "order status" FROM "orders";

String literals are always single-quoted in both. MySQL will also accept double quotes for strings unless ANSI_QUOTES mode is on. Postgres never will: a double-quoted token is always an identifier. Get in the habit of single-quoting strings and double-quoting identifiers and an entire class of errors disappears.

Identifier case is the quieter trap. Unquoted identifiers in Postgres are folded to lowercase. If you write CREATE TABLE Users, Postgres creates a table named users. Query "Users" with quotes afterwards and it will not be found, because the quoted form is case-sensitive and does not match.

sql — identifier case folding
-- Postgres folds this to `users`
CREATE TABLE Users (id SERIAL PRIMARY KEY);

SELECT * FROM Users;    -- works, folds to users
SELECT * FROM users;    -- works
SELECT * FROM "users";  -- works, quoted lowercase matches
SELECT * FROM "Users";  -- ERROR: relation "Users" does not exist

MySQL's behaviour here depends on the filesystem: table names are case-sensitive on Linux and case-insensitive on macOS and Windows by default, which is its own source of "works on my machine" bugs. Postgres is at least consistent across platforms.

The practical rule for both: use snake_case, all lowercase, never quoted. Then the difference stops mattering entirely.

Data Types: The Translation Table

This is the table you will come back to most often in your first month.

MySQL to PostgreSQL data type equivalents
ConceptMySQLPostgreSQL
Auto-incrementing IDAUTO_INCREMENTSERIAL / BIGSERIAL, or GENERATED ALWAYS AS IDENTITY
BooleanTINYINT(1), stores 1 or 0BOOLEAN, stores true or false natively
Variable textVARCHAR(n), TEXTVARCHAR(n), TEXT (no storage penalty for TEXT, so it is used freely)
Timestamp, no timezoneDATETIMETIMESTAMP
Timestamp with timezoneTIMESTAMP (converts to UTC using session timezone)TIMESTAMPTZ (the recommended default for most apps)
JSONJSON (validated text, no indexing of paths)JSON (text) and JSONB (binary, indexable, faster to query)
ArraysNot supported nativelyNative, for example INTEGER[] or TEXT[]
UUIDCHAR(36) or BINARY(16)Native UUID type, 16 bytes
EnumerationsENUM('a','b') inline on the columnCREATE TYPE ... AS ENUM, a reusable schema object
Fixed precision moneyDECIMAL(10,2)NUMERIC(10,2) (DECIMAL is an alias)
Unsigned integersINT UNSIGNEDNo unsigned types, use a CHECK constraint
IP addressesVARCHAR(45) or INTNative INET and CIDR types
Full-width comparison diagram mapping MySQL data types in the left column to their PostgreSQL equivalents in the right column, covering AUTO_INCREMENT to SERIAL, TINYINT(1) to BOOLEAN, JSON to JSONB, and highlighting PostgreSQL-only types such as arrays, UUID, and INET that have no MySQL equivalent
The type map at a glance. The right column entries with no left-hand pair are the Postgres-only types worth learning early.

The SERIAL swap trips up almost everyone on their first Postgres table.

sql — creating a table
-- MySQL
CREATE TABLE users (
  id INT AUTO_INCREMENT PRIMARY KEY,
  email VARCHAR(255) NOT NULL UNIQUE,
  is_active TINYINT(1) DEFAULT 1,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- PostgreSQL
CREATE TABLE users (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  email TEXT NOT NULL UNIQUE,
  is_active BOOLEAN NOT NULL DEFAULT true,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

SERIAL still works and you will see it constantly in existing codebases, so recognise it. It is not a real type: it is shorthand that creates an INTEGER column, a sequence, and a default. New code should prefer GENERATED ALWAYS AS IDENTITY, which is the SQL-standard equivalent, cannot be accidentally overwritten by a manual insert, and does not leave an orphaned sequence behind when you drop the column.

Two more choices in that snippet are deliberate. TEXT instead of VARCHAR(255) costs nothing in Postgres and saves you a migration the first time an email exceeds your guessed limit. TIMESTAMPTZ instead of TIMESTAMP stores an absolute point in time rather than a wall-clock reading with no timezone attached, which is what you almost always actually want.

⚠ TIMESTAMPTZ does not store a timezone

Despite the name, TIMESTAMPTZ stores a UTC instant and converts on input and output using the session TimeZone setting. It does not remember which zone the value came from. If you need the original zone for display, store it in a separate column.

Query Syntax You Will Write Every Day

String concatenation is the first difference you will hit, usually within an hour.

sql — string concatenation
-- MySQL
SELECT CONCAT(first_name, ' ', last_name) AS full_name FROM users;

-- PostgreSQL, idiomatic
SELECT first_name || ' ' || last_name AS full_name FROM users;

CONCAT() also exists in Postgres, so this one is forgiving, but the two are not interchangeable. CONCAT() treats NULL as an empty string, while || returns NULL if any operand is NULL. A user with no last_name gives you 'Ada ' from CONCAT() and NULL from ||. Pick deliberately, and reach for concat_ws(' ', first_name, last_name) when you want NULL-skipping behaviour with a separator.

Upserts are where the syntax genuinely diverges rather than just looking different.

sql — upsert
-- MySQL
INSERT INTO users (id, email) VALUES (1, 'a@example.com')
ON DUPLICATE KEY UPDATE email = VALUES(email);

-- PostgreSQL
INSERT INTO users (id, email) VALUES (1, 'a@example.com')
ON CONFLICT (id) DO UPDATE SET email = EXCLUDED.email;

-- PostgreSQL, insert-or-ignore
INSERT INTO users (id, email) VALUES (1, 'a@example.com')
ON CONFLICT DO NOTHING;

Postgres requires you to name the conflicting column or constraint explicitly. That is more verbose, and also more predictable: you are not relying on the database to decide which unique key triggered the conflict when a row violates two of them at once. EXCLUDED is the pseudo-table holding the row you tried to insert, filling the role VALUES() plays in MySQL. If you are unsure which conflict action you actually want, our ON CONFLICT clause picker walks you to the right one in three questions.

LIMIT and OFFSET behave the same in both, so that is one thing you do not need to relearn. The MySQL shorthand LIMIT 10, 20 is not valid in Postgres though: write LIMIT 20 OFFSET 10.

Several everyday functions are named differently. These are the ones that come up constantly:

Common function equivalents
TaskMySQLPostgreSQL
Current timestampNOW()now() or CURRENT_TIMESTAMP
First non-null valueIFNULL(a, b)COALESCE(a, b)
Conditional valueIF(cond, a, b)CASE WHEN cond THEN a ELSE b END
Cast a valueCAST(x AS SIGNED)x::integer or CAST(x AS integer)
Group into a stringGROUP_CONCAT(name)string_agg(name, ',')
Substring positionLOCATE(sub, str)position(sub IN str) or strpos(str, sub)
Random row orderORDER BY RAND()ORDER BY random()
Date arithmeticDATE_ADD(d, INTERVAL 7 DAY)d + INTERVAL '7 days'
Regex matchcol REGEXP 'pattern'col ~ 'pattern'
Case-insensitive LIKELIKE (collation dependent)ILIKE

That last row deserves attention. LIKE in MySQL is case-insensitive by default because the default collation is case-insensitive. In Postgres, LIKE is always case-sensitive, and ILIKE is the explicit case-insensitive form. A search feature ported straight across will silently return fewer results rather than error, which is exactly the kind of quiet failure worth watching for.

Both databases support CTEs and window functions with standard syntax, so WITH ... AS (...) and ROW_NUMBER() OVER (PARTITION BY ...) port across unchanged. Postgres additionally supports data-modifying CTEs, which let you write a delete and an insert as one atomic statement:

sql — data-modifying CTE
WITH archived AS (
  DELETE FROM orders
  WHERE created_at < now() - INTERVAL '1 year'
  RETURNING *
)
INSERT INTO orders_archive SELECT * FROM archived;

RETURNING has no MySQL equivalent and is worth adopting immediately. Every INSERT, UPDATE, and DELETE can return the affected rows, which removes the follow-up SELECT you would otherwise write to get a generated ID back.

JSON and JSONB: Where Postgres Pulls Ahead

Postgres has two JSON types. JSON stores the exact text you gave it, preserving key order and whitespace. JSONB parses it into a binary form, discards insignificant whitespace and duplicate keys, and supports indexing. Use JSONB unless you specifically need to round-trip the original text byte for byte.

sql — querying JSONB
-- -> returns JSON, ->> returns text
SELECT data->>'email' AS email
FROM users
WHERE data->>'status' = 'active';

-- Nested access
SELECT data->'address'->>'city' AS city FROM users;

-- Path access, Postgres 14+
SELECT data['address']['city'] FROM users;

-- Containment: does the document contain this fragment?
SELECT * FROM users WHERE data @> '{"role": "admin"}';

-- Key existence
SELECT * FROM users WHERE data ? 'deleted_at';

Mixing up -> and ->> is the most common beginner mistake, and it usually surfaces as a confusing type error rather than an obviously wrong result. The rule: -> keeps you inside JSON so you can keep chaining, ->> exits to text so you can compare, cast, or return it.

The real advantage is indexing. A GIN index over a JSONB column makes containment queries use an index instead of scanning every row and parsing every document.

sql — indexing a JSONB column
-- Broad: supports @>, ?, ?& and ?| on any key
CREATE INDEX idx_users_data ON users USING GIN (data);

-- Narrow and much smaller: supports @> only
CREATE INDEX idx_users_data_path ON users USING GIN (data jsonb_path_ops);

-- For equality on one known key, a plain B-tree expression index wins
CREATE INDEX idx_users_status ON users ((data->>'status'));
Terminal screenshot of PostgreSQL EXPLAIN ANALYZE output for a JSONB containment query, showing a Bitmap Index Scan on a GIN index followed by a Bitmap Heap Scan, with execution time and rows removed by filter visible, contrasted against a sequential scan plan for the same query without the index
The same containment query with and without the GIN index. Bitmap Index Scan on the left, Seq Scan on the right.

MySQL's JSON type supports similar extraction with JSON_EXTRACT() and the ->> shorthand, but it cannot index a JSON column directly. The workaround is a generated column plus an index on that column, which works for known paths and not for ad-hoc containment queries. If your data model is genuinely semi-structured, this difference alone is often the reason teams pick Postgres.

That said, JSONB is not a substitute for a schema. Columns you filter, join, or aggregate on belong in real columns with real types and real constraints. Reach for JSONB for the genuinely variable tail of your data. If you are weighing a document database instead, our NoSQL reference covers where that trade-off actually lands.

Indexing: GIN, GiST, and Partial Indexes

Both databases default to B-tree indexes and the basic CREATE INDEX syntax is nearly identical. Where Postgres pulls ahead is in index types MySQL has no answer for.

  • GIN: built for JSONB, arrays, and full-text search vectors. Slower to write, very fast for containment and membership lookups.
  • GiST: geometric data, range types, and nearest-neighbour searches. This is what PostGIS builds on.
  • BRIN: tiny indexes for very large tables whose values correlate with physical row order, such as an append-only events table keyed by timestamp.
  • Hash: equality only, crash-safe and replicated since Postgres 10. Rarely worth choosing over a B-tree.
  • Partial indexes: any index type restricted to rows matching a WHERE clause.
  • Expression indexes: index the result of a function call, such as lower(email), so a case-insensitive lookup can use it.
sql — partial and expression indexes
-- Only index rows you actually query
CREATE INDEX idx_active_users ON users (email) WHERE is_active = true;

-- Case-insensitive uniqueness, enforced by the index
CREATE UNIQUE INDEX idx_users_email_lower ON users (lower(email));

-- Build without locking writes on a live table
CREATE INDEX CONCURRENTLY idx_orders_created ON orders (created_at);

Partial indexes are the underrated one. On a table where ninety percent of rows are archived or soft-deleted and you only ever query the rest, a partial index can be a fraction of the size of the full index, which means fewer pages read per lookup and less write amplification on every insert. MySQL has no direct equivalent.

CREATE INDEX CONCURRENTLY is the other habit to build early. A plain CREATE INDEX takes a lock that blocks writes for the duration, which on a large production table means an outage. The concurrent form takes longer and cannot run inside a transaction block, but it lets writes continue.

ℹ Read the plan, not the docs

EXPLAIN (ANALYZE, BUFFERS) is the fastest way to learn Postgres indexing. It shows the plan the planner actually chose, the real row counts next to its estimates, and how many pages were read. When the estimate and the actual differ by orders of magnitude, stale statistics or a missing index are usually why.

Transactions, DDL, and the Concurrency Model

Postgres supports transactional DDL. You can wrap CREATE TABLE, ALTER TABLE, or DROP TABLE inside a BEGIN and COMMIT block and roll the whole thing back if something goes wrong partway through.

sql — transactional DDL
BEGIN;

ALTER TABLE users ADD COLUMN phone TEXT;
ALTER TABLE users ADD CONSTRAINT phone_unique UNIQUE (phone);
UPDATE users SET phone = legacy_phone WHERE legacy_phone IS NOT NULL;

-- Something looks wrong. Back out cleanly, schema and data together.
ROLLBACK;

MySQL cannot do this. DDL statements there implicitly commit, so a migration that fails on step three leaves your schema half-changed with no clean way back. If your team runs migrations by hand, or through a tool that does not handle partial failure well, this single difference is a real production safety net. It is also why Postgres migration tooling tends to be simpler: the database does the hard part. Our guide to Drizzle ORM migrations with drizzle-kit shows what that looks like in a typed TypeScript workflow.

Both databases default to READ COMMITTED isolation, but the behaviour under that level differs in a way that matters. In MySQL, a SELECT ... FOR UPDATE blocked by another transaction waits and then reads the newly committed row. Postgres does the same, but its REPEATABLE READ is a true snapshot isolation that will abort your transaction with a serialization failure rather than silently give you a different result. Application code talking to Postgres at REPEATABLE READ or SERIALIZABLE needs retry logic. This is a feature, not a bug, but it is code you did not have to write against MySQL.

Row-level security is another Postgres-only capability with no MySQL counterpart. Policies live in the database rather than the application, which means a missed WHERE tenant_id = ? cannot leak another tenant's rows. It is also easy to get subtly wrong, so it belongs in your test suite: see testing PostgreSQL row-level security policies in Jest for a working setup.

The same transactional guarantees are strong enough that teams increasingly run job queues and workflow state directly in Postgres rather than adding a dedicated engine. We compared that trade-off in detail in Postgres as a durable workflow engine versus Temporal.

Things That Will Bite You

These are the ones that cost real debugging hours, ordered roughly by how often they come up.

  1. `GROUP BY` is strict. MySQL historically let you select columns not in the GROUP BY and picked an arbitrary value. Postgres rejects the query. Every selected column must be grouped or aggregated, or wrapped in a window function.
  2. Integer division stays integer. SELECT 5 / 2 returns 2 in both, but MySQL returns 2.5 because it promotes to decimal. Cast explicitly: 5::numeric / 2.
  3. No implicit type coercion in comparisons. WHERE id = '42' works, since the literal is untyped and gets resolved, but WHERE some_text_col = 42 errors. MySQL would coerce and, worse, would coerce 'abc' to 0 and match rows you did not expect.
  4. Sequences are not rolled back. A failed insert still consumes the sequence value, so identity columns have gaps. This is correct behaviour, not a bug, and code that assumes contiguous IDs will break.
  5. `NULL` sorts differently. Postgres puts NULL last in ORDER BY ASC by default, MySQL puts it first. Use NULLS FIRST or NULLS LAST explicitly if the order matters.
  6. No `INSERT IGNORE`, no `REPLACE INTO`. Use ON CONFLICT DO NOTHING and an explicit upsert respectively. There is no direct REPLACE equivalent, because delete-then-insert has different foreign key and trigger consequences.
  7. Connection cost is real. Postgres forks an OS process per connection. A pooler is not optional at any real scale, and the default max_connections of 100 is not a target to raise, it is a hint to pool.
  8. `VACUUM` is not optional either. Autovacuum handles most workloads, but a table with heavy update churn and a default configuration will bloat. Watch n_dead_tup in pg_stat_user_tables.

⚠ Budget for a connection pooler before launch

PgBouncer in transaction pooling mode is the standard answer, and most managed Postgres providers ship one. It changes behaviour you may rely on: session-level state such as SET, prepared statements, and advisory locks do not survive across pooled transactions. Find that out in staging, not in an incident.

When to Actually Pick PostgreSQL vs MySQL

Both databases are mature and both will handle the vast majority of real applications without breaking a sweat. Anyone telling you one is simply better is selling something. The honest decision factors are narrower than the comparison posts suggest.

Reach for PostgreSQL when your data is semi-structured and you want it indexed, when your queries involve complex joins, CTEs, or analytical aggregation, when you need geospatial support, or when you are anywhere near vector search and embeddings. The extension ecosystem, PostGIS, pgvector, TimescaleDB, does things MySQL has no answer for. Transactional DDL and row-level security are also genuine operational advantages on teams that ship schema changes often.

Stay on MySQL when your team already runs it well, when your workload is high-volume simple lookups on a primary key, when you are deep in a WordPress or LAMP-adjacent ecosystem, or when you need very high connection counts without a pooling layer. MySQL's replication story is also older and more widely operationally understood, which matters more than benchmark numbers when something breaks at 3am.

The factor that decides it in practice is usually not technical at all: it is which one your team can operate confidently. A well-tuned MySQL beats a badly maintained Postgres every single time, and the reverse is equally true.

Quick Reference Cheat Sheet

Bookmark this section. It is the part you will reopen six months from now when you forget which operator does what.

MySQL to PostgreSQL quick reference
TaskMySQLPostgreSQL
Auto-incrementid INT AUTO_INCREMENTid BIGINT GENERATED ALWAYS AS IDENTITY
Boolean columnTINYINT(1) DEFAULT 1BOOLEAN DEFAULT true
Quote an identifier`order status`"order status"
Concatenate stringsCONCAT(a, b)a || b
UpsertON DUPLICATE KEY UPDATEON CONFLICT (col) DO UPDATE
Insert or ignoreINSERT IGNOREON CONFLICT DO NOTHING
Read a JSON fieldJSON_EXTRACT(d, '$.email')d->>'email'
Case-insensitive searchLIKEILIKE
Return the inserted rowSELECT LAST_INSERT_ID()INSERT ... RETURNING *
List tablesSHOW TABLES\dt in psql
Describe a tableDESCRIBE users\d users in psql
Show the query planEXPLAIN SELECT ...EXPLAIN (ANALYZE, BUFFERS) SELECT ...
Limit with offsetLIMIT 10, 20LIMIT 20 OFFSET 10
Current databaseSELECT DATABASE()SELECT current_database()
Printable quick-reference card summarising the main MySQL to PostgreSQL differences, grouped into four sections: identifiers and quoting, auto-increment and booleans, upsert syntax, and JSON access operators, with the MySQL form on the left and the PostgreSQL equivalent on the right of each row
A printable version of the cheat sheet. Pin it next to your editor for the first few weeks.
  • Identifiers are lowercase snake_case and unquoted everywhere
  • Primary keys use GENERATED ALWAYS AS IDENTITY, not SERIAL
  • Timestamp columns are TIMESTAMPTZ, defaulting to now()
  • Booleans are BOOLEAN, not integers
  • Case-insensitive searches use ILIKE or an expression index on lower()
  • Indexes on live tables are created with CONCURRENTLY
  • A connection pooler is in front of the database before launch
  • Autovacuum settings reviewed for any high-churn table

Frequently Asked Questions

Frequently Asked Questions

Is PostgreSQL faster than MySQL?

It depends entirely on the workload, and for most applications the answer will not decide anything.

Postgres tends to win on complex queries: multi-table joins, subqueries, CTEs, and analytical aggregation, because its planner considers more strategies and its parallel query support is stronger. MySQL tends to win on very high volumes of simple primary-key lookups, because its planner has less overhead per query and its connection model is cheaper.

In practice your query design, indexing strategy, and whether you have a caching layer will matter far more than the engine choice. If throughput is the concern, our guide to caching strategies is a better place to spend the first hour than a benchmark comparison.

Can I run my existing MySQL queries directly against PostgreSQL?

Simple SELECT and JOIN statements usually work unchanged. Anything touching MySQL-specific syntax needs rewriting.

  • Usually fine: SELECT, JOIN, WHERE, GROUP BY with fully grouped columns, LIMIT and OFFSET in the standard form, CTEs, window functions.
  • Needs a rewrite: backtick identifiers, AUTO_INCREMENT, ON DUPLICATE KEY UPDATE, INSERT IGNORE, REPLACE INTO, IFNULL, GROUP_CONCAT, LIMIT 10, 20.
  • Needs a full rewrite: stored procedures and triggers. MySQL's procedural syntax and PL/pgSQL are different languages, not dialects.

The queries that worry me most are the ones that run without error and return different results, such as a LIKE search that was case-insensitive in MySQL and is not in Postgres. Those do not show up in a smoke test.

Do I really need a connection pooler for PostgreSQL in production?

Yes, in almost every real deployment. Postgres forks a full OS process per connection, each holding its own memory for sorts, caches, and catalogs. A few hundred idle connections is measurable RAM and context-switching cost before a single query runs.

PgBouncer in transaction pooling mode is the standard choice and most managed providers offer one. Be aware of what it changes: session-level state does not survive between pooled transactions, so SET search_path, session advisory locks, and some prepared statement patterns behave differently. Serverless and edge runtimes make this worse, because each function instance opens its own connection, which is exactly the case pooling was built for.

Why does my Postgres table look empty when I query it with quotes?

You almost certainly created it with a mixed-case name without quotes. Postgres folds unquoted identifiers to lowercase, so CREATE TABLE Users creates users. Querying "Users" with quotes asks for a different, case-sensitive identifier that does not exist.

sql
-- Confirm what the table is actually called
SELECT tablename FROM pg_tables WHERE schemaname = 'public';

-- Rename it to the unquoted-friendly form if an ORM created it quoted
ALTER TABLE "Users" RENAME TO users;

ORMs that quote every identifier are the usual source of genuinely mixed-case tables. If yours does, configure it to snake_case rather than fighting the quoting in every hand-written query.

How hard is migrating an existing app from MySQL to PostgreSQL?

It scales with how much MySQL-specific behaviour your code depends on, not with data volume.

If you are on a solid ORM with little raw SQL, the migration is mostly mechanical: convert the schema, map the types, move the data with a tool like pgloader, then fix the handful of raw queries. A week or two of work for a mid-sized app.

If you have hand-written SQL throughout, stored procedures, triggers, or code relying on MySQL's permissive type coercion, expect real engineering time. The hidden cost is usually data quality: rows that MySQL accepted through coercion, such as '0000-00-00' dates or strings in numeric columns, will be rejected on import and need cleaning first.

💡 Tip

Run the schema conversion early, against production data, in a throwaway environment. The import errors are your actual migration backlog, and finding them in week one is far cheaper than finding them during a cutover window.

Should I use SERIAL or GENERATED ALWAYS AS IDENTITY?

Use GENERATED ALWAYS AS IDENTITY for new tables. It is the SQL-standard form, it is what Postgres itself recommends, and it fixes two real problems with SERIAL.

  • Ownership: a SERIAL column's sequence is a separate object that can be left orphaned, or have its permissions drift out of sync with the table.
  • Accidental overwrite: SERIAL lets an application insert an explicit ID, which silently desynchronises the sequence and causes duplicate key errors later. GENERATED ALWAYS rejects that unless you write OVERRIDING SYSTEM VALUE.

You will still meet SERIAL in every older codebase, so recognise it and leave it alone. Converting an existing column is possible but not worth the migration risk on its own.

Why does my PostgreSQL database get slower over time when the data barely grows?

This is table bloat, and it is the single most common Postgres surprise for people arriving from InnoDB.

Every UPDATE in Postgres writes a new row version and marks the old one dead. Autovacuum reclaims that space in the background, but on a table with heavy update churn it can fall behind, and the table plus its indexes keep growing with tuples nobody can see. Scans read more pages for the same number of live rows, so queries slow down without your data or traffic changing.

sql
SELECT relname,
       n_live_tup,
       n_dead_tup,
       last_autovacuum
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 10;

If n_dead_tup is a large fraction of n_live_tup on a hot table, tune autovacuum for that table specifically rather than globally: lower autovacuum_vacuum_scale_factor so it triggers more often on large tables. VACUUM FULL reclaims the space properly but takes an exclusive lock, so it is a maintenance-window operation, not a fix you run during traffic.

None of this makes Postgres harder than MySQL. It makes it different in a small number of specific, learnable ways, and most of those differences exist because Postgres refuses to guess what you meant. Once the strictness stops feeling like friction, it starts feeling like a safety net.

Work through the checklist above on your next table, read one EXPLAIN (ANALYZE, BUFFERS) plan properly, and set up a pooler before you need one. That covers most of the distance between knowing SQL and being productive in Postgres.

Written by

Zeeshan Tofiq, Full Stack Developer
Zeeshan Tofiq

Full Stack Developer

Full stack developer with over 6 years of experience building production applications. Writes practical guides on JavaScript, TypeScript, React, Node.js, and cloud infrastructure. Focused on helping developers solve real problems with clean, maintainable code.

All articles by Zeeshan TofiqGitHubLinkedIn

Enjoyed this article?

Get practical dev guides, tool updates, and new articles delivered straight to your inbox. No spam, unsubscribe anytime.

Related Articles

databases

Testing PostgreSQL RLS Policies With Jest (No pgTAP)

Test PostgreSQL row-level security with Jest and node-postgres, no pgTAP required. Catch tenant isolation bugs in CI before a bad grant reaches production.

Aug 21, 2026·14 min read
databases

pg_durable: Durable Workflows in PostgreSQL vs Temporal and Cron+Queue

Microsoft's pg_durable brings durable workflow execution inside PostgreSQL. Here is the syntax, what it replaces, and an honest comparison against Temporal and cron+queue patterns.

Jun 23, 2026·29 min read
databases

40 SQL Interview Questions and Answers (2026)

40 SQL and relational database interview questions covering joins, indexes, ACID, window functions, CTEs, MySQL vs PostgreSQL, and query challenges.

Jun 14, 2026·47 min read

On this page

  • Where MySQL Habits Break in PostgreSQL
  • The Mental Model Shift
  • Syntax, Quoting, and Identifier Case
  • Data Types: The Translation Table
  • Query Syntax You Will Write Every Day
  • JSON and JSONB: Where Postgres Pulls Ahead
  • Indexing: GIN, GiST, and Partial Indexes
  • Transactions, DDL, and the Concurrency Model
  • Things That Will Bite You
  • When to Actually Pick PostgreSQL vs MySQL
  • Quick Reference Cheat Sheet
  • Frequently Asked Questions