DEV Community

Cover image for When Code "Works" by Accident: Hunting Down an Undocumented Fallback in node-pg-migrate 🔍
Lucas Ferreira
Lucas Ferreira

Posted on

When Code "Works" by Accident: Hunting Down an Undocumented Fallback in node-pg-migrate 🔍

Summer Bug Smash: Smash Stories Submission 🐛🛹

This is a submission for DEV's Summer Bug Smash: Smash Stories powered by Sentry.

We've all spent hours debugging a broken piece of code. But what happens when your code is supposed to fail, but it actually succeeds?

Recently, while going through Curso.dev (a popular web development course in Brazil), I decided to stray from the "happy path." I wanted to intentionally trigger a database connection error using the node-pg-migrate package.

Specifically, I wanted to see how the tool would behave if I forgot to pass the --envPath flag, which should have prevented it from accessing the DATABASE_URL variable.

My expectation: Since DATABASE_URL was empty, I expected a clear connection error (something like client password must be a string or a generic connection failure).

Instead, this happened:

$ npm run migrate:up

> clone-tabnews@1.0.0 migrate:up
> node-pg-migrate --migrations-dir infra/migrations up

> Migrating files:
> - 1783567146909_inital
> - 1783567172809_seconde
### MIGRATION 1783567146909_inital (UP) ###
INSERT INTO "public"."pgmigrations" (name, run_on) VALUES ('1783567146909_inital', NOW());

### MIGRATION 1783567172809_seconde (UP) ###
INSERT INTO "public"."pgmigrations" (name, run_on) VALUES ('1783567172809_seconde', NOW());

Migrations complete!

Enter fullscreen mode Exit fullscreen mode

The migrations ran and applied successfully. They connected to my cloud database on Neon without any DATABASE_URL configured. 🤯

Here is how I went down the node_modules rabbit hole to figure out why.


Investigating the Unexpected

One of the best lessons in software engineering is that we need to investigate not only when code breaks, but also when it behaves differently from what we expect.

So, I started digging into the source code of the installed packages.

1. The Version

Inside node_modules/node-pg-migrate/package.json:

{
  "name": "node-pg-migrate",
  "version": "6.2.2",
  "bin": { "node-pg-migrate": "bin/node-pg-migrate" }
}

Enter fullscreen mode Exit fullscreen mode

2. The .env setup

In my project's root .env file, DATABASE_URL was completely empty, but I had standard PG* connection parameters (used by the default pg module for other API endpoints):

DATABASE_URL=

# Default libpq Connection Parameters
PGHOST='***-***-***-***-pooler.c-7.us-east-1.aws.neon.tech'
PGDATABASE='neondb'
PGUSER='neondb_owner'
PGPASSWORD='****************s'
PGSSLMODE='require'

Enter fullscreen mode Exit fullscreen mode

3. Loading the environment variables

Looking at node_modules/node-pg-migrate/bin/node-pg-migrate (lines 217-239), I saw how the CLI loads environment variables:

/* Load env before accessing process.env */
const dotenv = tryRequire('dotenv')
if (dotenv) {
  // Load config from ".env" file
  const myEnv = dotenv.config(dotenvConfig)
  const dotenvExpand = tryRequire('dotenv-expand')
  if (dotenvExpand && dotenvExpand.expand) {
    dotenvExpand.expand(myEnv)
  }
}

let DB_CONNECTION = process.env[argv[databaseUrlVarArg]] // process.env.DATABASE_URL

Enter fullscreen mode Exit fullscreen mode

Because dotenv was a dependency in my project, the CLI automatically loaded the .env file and populated process.env with those PG* variables.

4. The Undocumented Fallback

Here is the culprit. Since DATABASE_URL was empty, DB_CONNECTION was evaluated as falsy, triggering the fallback block in the CLI (lines 380-387):

if (!DB_CONNECTION) {
  const cp = new ConnectionParameters()
  if (!cp.host && !cp.port && !cp.database) {
    console.error(`The $${argv[databaseUrlVarArg]} environment variable is not set.`)
    process.exit(1)
  }
  DB_CONNECTION = cp
}

Enter fullscreen mode Exit fullscreen mode

The new ConnectionParameters() class (which comes from the underlying pg library) automatically looks for PGHOST, PGUSER, PGPASSWORD, etc., in the environment when no connection string is provided.

Because those variables were present in my .env, node-pg-migrate silently accepted them, bypassed the error, and successfully connected to the database anyway.


Documentation vs. Reality

If you look at the official node-pg-migrate documentation, it states:

"Now you should put your DB connection string to DATABASE_URL environment variable and run npm run migrate up."

The documentation implies that DATABASE_URL is required. The fallback to standard PG* environment variables is actually an undocumented implementation detail.

While this works, relying on undocumented fallbacks is risky for a couple of reasons:

  1. Fragility: If dotenv is missing or configured differently, this silent fallback fails instantly.
  2. Configuration Drift: You might assume your migrations are running locally, when they are actually applying to a remote database defined in your PGHOST variables.

The Takeaway

They say "magic" in code is just logic we haven't read yet. When a piece of software does something unexpected, even if that "something" is succeeding when it should have failed, it's always worth digging in to understand why.

This investigation made it clear exactly how node-pg-migrate behaves under the hood:

  1. It attempts to load .env automatically using tryRequire('dotenv').
  2. It falls back to ConnectionParameters() if DATABASE_URL is missing.
  3. It uses standard PostgreSQL environment variables as a fallback option.

This is also a great reminder of why comprehensive application monitoring and observability are so vital. When software fails, we usually get a loud error alert. But when software 'silently succeeds' through undocumented fallbacks, it bypasses traditional error catchers. Without proper environment auditing and logging, these silent configurations can drift into production completely unnoticed.

To keep things predictable, I updated my setup to explicitly define DATABASE_URL rather than relying on this silent fallback. Predictable code is always safer!

Have you ever run into an undocumented fallback that saved (or almost broke) your setup? Let me know!

Top comments (4)

Collapse
 
mads_hansen_27b33ebfee4c9 profile image
Mads Hansen

This is a great example of why “it succeeded” is not always the happy path.

For migration tooling, I like making the resolved connection target visible before any destructive or persistent action: host, database, user, SSL mode, and which env source won. In CI I would go even further and fail closed if DATABASE_URL is expected but empty, instead of allowing PG* fallback unless it is explicitly enabled.

A successful misconfiguration is worse than a loud failure because it leaves real state behind.

Collapse
 
lksferreira profile image
Lucas Ferreira

Thanks, Mads! I completely agree. A silent success that leaves a misconfigured state behind is a nightmare to debug. Showing the resolved connection target and forcing the CI pipeline to fail closed are excellent best practices. I'll definitely carry those tips forward. Appreciate the feedback!

Collapse
 
wrencalloway profile image
Wren Calloway

The thing you found isn't really an undocumented node-pg-migrate quirk — it's baseline libpq behavior leaking through, and that's the part worth internalizing. ConnectionParameters in pg reads PGHOST/PGUSER/etc. by design, because it mirrors libpq's environment variable contract. So this exact "silent success" reproduces with plain pg, psql, pg_dump, and basically anything on the Postgres client stack. node-pg-migrate didn't invent a fallback; it just didn't shield you from one that's been there for decades.

That reframe changes your mitigation. Setting DATABASE_URL explicitly fixes this tool but leaves the actual footgun loaded — any other pg-based script in the repo still silently picks up those PG* vars. The real drift risk is a dev running an ad-hoc node script.js and hitting Neon prod without ever typing a connection string.

If you genuinely want migrations to fail loud when misconfigured, the sharper move is to not have live PG* credentials sitting in a shared .env at all — scope them per-command or use a distinct var you pass in deliberately. The environment being ambient is the bug; the empty DATABASE_URL just exposed it.

Collapse
 
lksferreira profile image
Lucas Ferreira

Thanks for the insights, Wren!

You're absolutely right about the underlying libpq/pg behavior and the broader security risks of ambient environment variables in a shared .env.

My main focus in the article was highlighting the specific documentation gap in node-pg-migrate and how it handles an empty string, but your point adds a great layer of security context that everyone should keep in mind. Appreciate the feedback!