TypeScript

Fix: unable to determine transport target for pino-pretty

Reproduce the exact failure in a clean container, then pick the right fix: skip the transport outside production, install pino-pretty as a real dependency, use it as a stream, or wire up a bundler plugin.

Mahdi Ben RhoumaMahdi Ben Rhouma
9 min read

TL;DR#

pino({ transport: { target: "pino-pretty" } }) throws unable to determine transport target for "pino-pretty" when pino cannot resolve the pino-pretty module from the files that called pino(). The two reproducible causes are: pino-pretty is not installed at all, or it is installed only as a devDependency and a production-only install never installed it. The fix that avoids it entirely is a transport conditional on NODE_ENV, kept a devDependency, with NODE_ENV=production genuinely set at runtime; otherwise install pino-pretty as a real dependency, use it as a stream, or wire up a bundler plugin.

The error#

text
/app/node_modules/pino/lib/transport.js:295
      throw new Error(`unable to determine transport target for "${origin}"`)
      ^
 
Error: unable to determine transport target for "pino-pretty"
    at fixTarget (/app/node_modules/pino/lib/transport.js:295:13)
    at transport (/app/node_modules/pino/lib/transport.js:261:30)
    at normalizeArgs (/app/node_modules/pino/lib/tools.js:349:16)
    at pino (/app/node_modules/pino/pino.js:92:28)

This is reported on Stack Overflow with the code that triggers it most often — a logger.js that configures a transport target directly: unable to determine transport target for "pino-pretty".

js
import pino from "pino";
 
const logger = pino({
  transport: {
    target: "pino-pretty",
    options: { colorize: true },
  },
});
 
export default logger;

Why it happens#

Since pino v7, a transport target such as "pino-pretty" is not loaded in the thread that calls pino(). It is loaded in a separate worker thread, and the worker needs an absolute module path, not a bare package name. To turn "pino-pretty" into that absolute path, pino's fixTarget() function walks the files that called pino.transport() and tries createRequire(callerFile).resolve("pino-pretty") against each one, stopping at the first that succeeds. Simplified from the current source:

js
for (const filePath of callers) {
  try {
    fixTarget = createRequire(filePath).resolve(origin)
    break
  } catch (err) {
    continue
  }
}
 
if (!fixTarget) {
  throw new Error(`unable to determine transport target for "${origin}"`)
}

(adapted from lib/transport.js, pinojs/pino; the real function also resolves targets/pipeline arrays and bundler overrides). If every caller fails to resolve pino-pretty, fixTarget stays unset and pino throws exactly this error.

Two situations reproduce that failure directly:

  1. pino-pretty is not installed. npm install pino was run, pino-pretty never was, and nothing in node_modules answers to that name.
  2. pino-pretty is installed, but only as a devDependency, and whatever runs the process does not install devDependencies. npm ci --omit=dev is a production-only install: it never puts anything listed under devDependencies into node_modules. Some platforms get there a different way — Heroku's own Node.js Classic Buildpack Builds page states that the buildpack installs devDependencies for the build, then "prunes out the packages declared under devDependencies before deploying the application", so a devDependency the running process needs is gone by the time it starts (the same mechanism breaks a babel-node start script, see Fix babel-node: not found on Heroku (devDependencies pruned)). A Docker build that skips devDependencies hits the same gap for the TypeScript compiler itself, see Fix: sh: tsc: not found in Docker build. Either way, the package that printed colourised logs locally is simply absent where the process actually runs, and the exact same "unable to determine transport target" error reappears — this time in production, not in npm run dev.

A third situation is named directly by pino's own documentation, without this article being able to reproduce it in a plain container: when the app is bundled into one file (a single compiled server entry point from webpack or esbuild). Pino's docs on bundling state it plainly: "due to its internal architecture based on Worker Threads, it is not possible to bundle Pino without generating additional files" — a bundler has to emit pino-pretty, pino/file, pino-worker and thread-stream-worker as separate files and register them on globalThis.__bundlerPathsOverrides. Pino's docs do not say which exact lookup step fails without that. Next.js carries the same risk, but its default serverExternalPackages list in both the current stable release (v16.4.2) and the canary branch already names pino, pino-pretty and pino-roll — pino-pretty's own entry there is commented 'A "plugin" that is passed to pino as a string (and is then required in a worker)' — so an app on a current Next.js version is less likely to hit this from bundling specifically. One reply in the same Stack Overflow thread, on a question opened in 2022, reports hitting this error after deploying an app built with next-pino, and that adding import 'pino-pretty' to the logger file made it go away — reported as a workaround by that answer, not explained by any source checked for this article.

Fix#

1. Make the transport conditional, and keep pino-pretty as a devDependency#

Pino's own docs on pretty printing say NDJSON "is perfect for production usage and long-term storage" and that it's "not so great for development environments", pointing to a prettifier such as pino-pretty for those. A Stack Overflow answer on this question (score 9) agrees that pino-pretty "should be a dev dependency". Following that, the fix is to only build the transport outside production, so pino-pretty never has to exist in the production node_modules at all — provided NODE_ENV=production is genuinely set wherever the process runs, since the ternary below falls back to the pino-pretty branch for any other value, including an unset one:

js
const pino = require("pino");
 
const logger = pino({
  transport:
    process.env.NODE_ENV === "production"
      ? undefined
      : { target: "pino-pretty", options: { colorize: true } },
});

Keep pino-pretty under devDependencies — no change there. Reproduced on Node 20.20.2 (node:20-alpine), pino 10.4.0, pino-pretty 13.2.0, 16 October 2026: with pino-pretty only in devDependencies and a fresh npm ci --omit=dev (so it is genuinely absent from node_modules), running with NODE_ENV=production prints plain NDJSON with no error at all — transport resolves to undefined and pino never calls fixTarget(). Running the same file with NODE_ENV=development (after reinstalling pino-pretty) prints the pretty-formatted line instead. As a negative control, the same absent-pino-pretty setup with NODE_ENV left unset throws the exact same transport error as before — the conditional only helps once the process that runs it actually sets NODE_ENV=production (a Dockerfile ENV NODE_ENV=production, or whatever the deploy platform sets).

2. Need pretty output in production too — move pino-pretty to a real dependency#

bash
npm uninstall pino-pretty
npm install --save pino-pretty

If pino-pretty is already listed under devDependencies, running npm install pino-pretty on its own is not enough: reproduced on the same setup, it leaves the package under devDependencies unchanged (npm keeps an existing dependency's current type). Uninstalling it first and reinstalling with --save moves it to dependencies. If the production process is meant to keep printing pretty logs, it has to be there, because a production-only install never brings in devDependencies. Reproduced the same way: with pino-pretty only in devDependencies, npm ci --omit=dev leaves it out of node_modules and the transport error returns, even though the same code had just logged fine after a plain npm install. Moving the package to dependencies and re-running the exact same npm ci --omit=dev installs it and the logger works — nothing else in the logger configuration changes.

A different-looking but related failure is a platform-specific optional dependency going missing after node_modules was built for another OS — not this error, but the same family of "the module isn't actually in node_modules for this run": see Fix Cannot find module @rollup/rollup-win32-x64-msvc.

3. Skip transport resolution entirely — use pino-pretty as a stream#

If you only need pretty output in one process and do not want to depend on pino's caller-stack resolution at all, require pino-pretty directly and pass it to pino() as a destination stream instead of a transport.target:

js
const pino = require("pino");
const pretty = require("pino-pretty");
 
const stream = pretty({ colorize: true });
const logger = pino({ level: "info" }, stream);
 
module.exports = logger;

pino's own docs say only the transport code runs in a worker thread (see "Since pino v7" above); loading pino-pretty with a plain require() like this stays outside that worker, so there is no module target for pino to resolve. Reproduced on the same Node 20.20.2 / pino 10.4.0 / pino-pretty 13.2.0 setup: with pino-pretty installed, this logs normally. With it uninstalled, require("pino-pretty") fails as a plain Node module load, not as pino's transport error:

text
Error: Cannot find module 'pino-pretty'
Require stack:
- /app/logger-stream.js

— a more standard MODULE_NOT_FOUND that most Node developers already know how to read. The stream usage itself is documented under "Usage as a stream" in pino-pretty's README.

4. Bundled into one file (webpack, esbuild, or an older Next.js) — add a bundler plugin#

If pino-pretty is installed and the error only shows up once the app is bundled or deployed as a single compiled entry point, pino's documentation names two routes, neither reproduced here: a bundler plugin — pino-webpack-plugin for Webpack, or esbuild-plugin-pino for esbuild — that generates the extra transport files and registers them for you, or setting globalThis.__bundlerPathsOverrides by hand with the paths your bundler actually emitted for pino-worker, pino/file, thread-stream-worker and pino-pretty (the docs list all four as required identifiers). On Next.js specifically, check first whether the installed version's default serverExternalPackages list already names pino/pino-pretty before adding either of the above. Reaching for fix 3 above (stream mode) also sidesteps this case, since the worker thread and its target resolution are never involved — at the cost of running pino-pretty's formatting outside that worker instead of inside it.

Verify the fix#

For fix 1, run with NODE_ENV=production against a node_modules built by npm ci --omit=dev (so pino-pretty is genuinely absent) and confirm a plain NDJSON line prints with no error:

text
{"level":30,"time":1792175093067,"pid":75,"hostname":"b815110f6b71","msg":"hello from conditional transport"}

Then run with NODE_ENV=development (with pino-pretty installed) and confirm the pretty-formatted line prints instead. For fix 2, run the process the same way it runs in the environment that failed — a fresh rm -rf node_modules && npm ci --omit=dev, not just npm install on a machine that already has node_modules cached — and confirm a coloured, formatted log line prints instead of the stack trace:

text
[18:19:39.836] INFO (305): hello from repro

If the error persists after either fix and pino-pretty is confirmed present in node_modules, the next case to rule out is bundling: check whether the process that threw the error is a single compiled bundle rather than plain node_modules-based code.

Pino's worker-thread transport design buys speed — formatting and writing logs off the main thread — at the cost of needing a resolvable module for every transport target, in whatever environment actually runs the process. Once that trade-off is visible, the reproduced fixes above cover the common reports of this error: skip the transport outside production, install the package where the running process will actually look for it, or skip the resolution step entirely by using a stream.

One email a month — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

Written by

Mahdi Ben Rhouma
Mahdi Ben Rhouma

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote