10 min read

A MySQL DBA's First Week with PostgreSQL

Your MySQL knowledge is an asset on PostgreSQL, but only after translation. This is the first-week mental-model guide: psql habits, catalogs, roles, config files, the vacuum mindset, and the sharp edges to respect early.

The hardest part of moving from MySQL to PostgreSQL is not learning new material, it is unlearning reflexes that no longer apply. A MySQL DBA arrives with a decade of muscle memory: SHOW commands, information_schema queries, user-at-host grants, my.cnf sections, and an InnoDB-shaped intuition about how storage behaves. Most of that knowledge has a PostgreSQL counterpart, but almost none of it transfers verbatim.

I made this move myself, and this is the guide I wish I had for the first week: not a feature tour, but a day-by-day translation of the mental models, plus an honest list of the sharp edges that cut new arrivals. The framing throughout is that MySQL habits are not wrong, they are simply calibrated for a different machine.

Day one: psql is not the mysql client, it is better once it clicks

The first friction is the shell. In the mysql client, everything is SQL: SHOW DATABASES, SHOW TABLES, DESCRIBE orders. In psql, introspection lives in backslash meta-commands: \l lists databases, \dt lists tables, \d orders describes a table with its indexes and constraints, \du lists roles, and \x toggles the vertical output you knew as \G. The commands feel cryptic for a day and become faster than their SQL spellings by day three, because they are terse and tab-completed.

Two habits worth building immediately: \timing on, which prints elapsed time per statement the way you relied on the mysql client's timing, and \e, which opens the current query in your editor. And one conceptual difference behind the client: a PostgreSQL connection is to one database, and cross-database queries do not work the way cross-schema queries did in MySQL. What MySQL calls a database is closest to a PostgreSQL schema; a PostgreSQL database is a harder boundary. Plan your namespace mapping with that in mind before you create anything.

Day two: catalogs, or where SHOW went

MySQL 8.x trained you to query information_schema and performance_schema for everything. PostgreSQL has information_schema too, for standards-compliant basics, but the native and far richer layer is pg_catalog plus the statistics views: pg_stat_activity for sessions, pg_stat_user_tables and pg_stat_user_indexes for object-level activity, pg_stat_database for cluster throughput, pg_locks for lock state, and pg_stat_statements, once the extension is installed, as the digest table you knew from events_statements_summary_by_digest.

The replacement for SHOW PROCESSLIST is the query you will run most in week one:

SELECT pid,
       usename,
       state,
       wait_event_type,
       wait_event,
       now() - query_start AS running_for,
       left(query, 100) AS query
FROM pg_stat_activity
WHERE pid <> pg_backend_pid()
ORDER BY query_start;

Notice what it gives you that PROCESSLIST did not: an explicit state column that separates active work from idle in transaction, and wait event columns that tell you what a session is blocked on. Learn those two columns early; they are the backbone of PostgreSQL diagnosis, and the PostgreSQL monitoring guide builds almost everything on top of them.

Day three: users are roles, and the host is gone

MySQL identity is user-at-host: app@'10.0.%' and app@'localhost' are different accounts with different grants. PostgreSQL splits that model in two. Identity is a role, with no host component, and network rules live in pg_hba.conf, which maps connection source, database, user, and authentication method. Roles can contain other roles, so groups and users are the same object type, and privileges are usually granted to group roles that login roles inherit.

SELECT rolname,
       rolsuper,
       rolcreaterole,
       rolcanlogin,
       rolconnlimit
FROM pg_roles
WHERE rolname NOT LIKE 'pg_%'
ORDER BY rolname;

Two adjustments matter in practice. First, GRANT behaves per-database-object, and default privileges for future objects need ALTER DEFAULT PRIVILEGES, which surprises everyone once. Second, editing pg_hba.conf requires a configuration reload before it takes effect, and a bad hba line is the classic new-arrival lockout. Change it the way you changed replication settings on MySQL: deliberately, with a tested rollback.

Day four: configuration without my.cnf sections

Where MySQL has my.cnf with its section headers, PostgreSQL has postgresql.conf, plus ALTER SYSTEM, which writes overrides to postgresql.auto.conf, roughly playing the part of SET PERSIST. The introspection habit translates cleanly: pg_settings is your SHOW VARIABLES, with better metadata, including whether a change needs a reload or a full restart via the context column.

The tuning instincts need recalibrating more than the mechanics. The reflex from InnoDB is to give the buffer pool most of the machine. PostgreSQL's shared_buffers is conventionally set far smaller, commonly around a quarter of memory, because PostgreSQL leans on the operating system page cache for the rest. work_mem is per-sort-or-hash, per query, per connection, not a global pool, so the safe value depends on concurrency; oversizing it is a classic out-of-memory cause. And max_connections is not the free headroom it was on MySQL, because each connection is a process. Sizing these together is its own topic, covered in the PostgreSQL sizing guide.

Day five: the vacuum mindset

This is the deepest model change, and it deserves a full day of attention. InnoDB stores old row versions in undo logs and purges them in the background; the table itself stays compact, and your monitoring instinct was history list length. PostgreSQL writes new row versions into the table and marks the old ones dead, and vacuum reclaims them later. Updates are closer to insert-plus-delete, tables and indexes carry the cost of unreclaimed versions as bloat, and anything holding an old snapshot, most often an idle-in-transaction session or an abandoned replication slot, blocks cleanup cluster-wide.

SELECT relname,
       n_live_tup,
       n_dead_tup,
       round(100.0 * n_dead_tup / nullif(n_live_tup + n_dead_tup, 0), 1)
           AS dead_pct,
       last_autovacuum,
       autovacuum_count
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 15;

The mindset shift: autovacuum is not an optional janitor, it is a load-bearing subsystem, and your job is to make sure it keeps up, not to switch it off when it shows up in the process list during a busy period. Also file away the term transaction ID wraparound. You will not hit it in week one, but it is the failure mode behind aggressive autovacuum behavior on old tables, and knowing it exists separates prepared operators from surprised ones.

The sharp edges I would flag on day five

A few things cut MySQL-trained hands reliably. Unquoted identifiers fold to lowercase, not to the case you typed, so a schema created with quoted CamelCase names becomes permanently annoying; pick lowercase snake_case and never quote. Strings are single-quoted only, and by default the double quote means identifier, not string. SELECT count(*) on a big table is a real scan, not a shortcut, because there is no clustered index metadata trick. There are no planner hints in core PostgreSQL, which feels like losing a tool until you learn to read EXPLAIN ANALYZE and fix statistics or indexes instead. Connections are expensive processes, so an application that opens hundreds of short-lived connections needs a pooler like PgBouncer earlier than it would on MySQL. And DDL is transactional, which is a gift, but it takes locks like any transaction, so a migration left uncommitted in a psql session can quietly block the world.

None of these is a design flaw; each is a coherent choice in PostgreSQL's model, the same way InnoDB's choices were coherent, as the broader PostgreSQL overview lays out. The edge exists where the two coherent models disagree.

Week two and beyond: give MonPG the watch

By the end of week one you can navigate psql, read the catalogs, manage roles, and reason about vacuum. What you do not have is the thing that made you effective on MySQL: a feel for this workload's normal. That took years to build there, and the honest way to shortcut it here is to start recording evidence immediately. That is what MonPG's PostgreSQL monitoring is for: it keeps pg_stat_statements history, session and wait-event state, lock chains, autovacuum and bloat signals, and replication lag in one workflow, so the baseline builds itself while you are still learning the terrain.

MonPG is PostgreSQL-only, so it will not cover any MySQL systems still in your fleet; keep your existing tooling for those. For the new platform, treat it as the colleague who has already been watching for months when the first real incident lands in week six, and you are asked the question every DBA knows: is this normal, or did something change?