DEV Community

Nursultan
Nursultan

Posted on

What breaks when you move Pipedream code steps to n8n (tested on 92 workflows)

Pipedream Workflows stops running on March 31, 2027, and the data is deleted by April 30, 2027. If you are moving to n8n, the triggers and app actions are the easy part. The code steps are where the work is.

I wanted numbers on that, so I collected every public Pipedream workflow I could find in GitHub Sync format: 92 workflows from 14 repositories. They have 391 steps, and 266 of them (68%) are code steps. 82 of the 92 workflows have at least one.

The test in three sentences

I converted the 84 non-empty workflows and imported each into the n8nio/n8n:2.44.5 Docker image with n8n import:workflow: 84 of 84 imported with no errors. Then I ran each one once with n8n execute, with no network, no credentials and no input, so the first error shows up fast. 8 ran to the end; 38 of the 76 failures came from that harsh setup (no input, no network, no credentials), and the rest are what this post is about.

The full results table and the per-workflow CSV are on GitHub.

One thing to know before the code: in my probe of the n8n 2.44.5 Code node with the default JavaScript task runner, these globals were not defined: fetch, URL, URLSearchParams, AbortController, structuredClone, Blob, process and the global crypto. require, Buffer, setTimeout, TextEncoder, atob, btoa and FormData were there.

1. fetch and axios

31 of the 92 workflows call fetch(, and 29 use axios. Neither works in the Code node out of the box. The n8n docs say the Code node can't make HTTP requests and point you to the HTTP Request node. That is the clean fix, and it also gives you credentials, pagination and retries.

If a step has a lot of logic around the calls and you want to move it first and refactor later, this.helpers.httpRequest is available in the Code node in my tests. It is documented for node development, not for the Code node, so treat it as something to test. A small shim keeps the old code working:

// Top of the Code node
const fetch = async (url, init = {}) => {
  const res = await this.helpers.httpRequest({
    url: String(url),
    method: init.method || 'GET',
    headers: init.headers,
    body: init.body,
    returnFullResponse: true,
    ignoreHttpStatusErrors: true,
  });
  return {
    ok: res.statusCode >= 200 && res.statusCode < 300,
    status: res.statusCode,
    headers: res.headers,
    json: async () => (typeof res.body === 'string' ? JSON.parse(res.body) : res.body),
    text: async () => (typeof res.body === 'string' ? res.body : JSON.stringify(res.body)),
  };
};
Enter fullscreen mode Exit fullscreen mode

It has no streaming and no signal, which is fine, because AbortController (used in 18 workflows) is not defined either. Remove the abort logic and use the HTTP Request node's timeout instead.

For axios, the call maps almost one to one:

// Pipedream
const { data } = await axios.post(url, payload, { headers });

// n8n Code node
const data = await this.helpers.httpRequest({ method: 'POST', url, body: payload, headers });
Enter fullscreen mode Exit fullscreen mode

2. steps.x.$return_value and $.export become items

A Pipedream step returns a value and exports named fields. An n8n Code node returns an array of items, each with a json key. Later nodes read them by node name.

// Pipedream, step "parse"
export default defineComponent({
  async run({ steps, $ }) {
    const email = steps.trigger.event.body.email;
    $.export("domain", email.split("@")[1]);
    return { email: email.toLowerCase() };
  },
});
// later: steps.parse.$return_value.email, steps.parse.domain
Enter fullscreen mode Exit fullscreen mode
// n8n Code node "parse", Run Once for All Items
const email = $('Webhook').first().json.body.email;
return [{ json: { email: email.toLowerCase(), domain: email.split('@')[1] } }];
// later: $('parse').first().json.email, $('parse').first().json.domain
Enter fullscreen mode Exit fullscreen mode

Keep the node names the same as the step names and the references stay readable. Note that $json only works in "Run Once for Each Item" mode. In the default all-items mode, use $input.first() or $input.all().

3. $.flow.exit and $.respond

$.respond( appears in 27 workflows and $.flow.* in 20. In n8n, both move out of the code.

$.flow.exit() becomes an empty return. No items means the next nodes don't run:

// n8n Code node
const body = $('Webhook').first().json.body;
if (!body.email) return [];
return [{ json: { email: body.email } }];
Enter fullscreen mode Exit fullscreen mode

The run counts as a success in n8n and the exit reason is not stored. If the stop should alert someone, use a Stop and Error node instead. If the condition is simple, an If or Filter node before the step shows it on the canvas.

$.respond() becomes a node. Set the Webhook node's Respond option to "Using 'Respond to Webhook' node", then put a Respond to Webhook node where the answer should go, with Respond With: JSON and the Response Code option. If the workflow fails before that node, the caller gets a 500. The common "respond 400 and exit" pattern becomes an If node with a Respond to Webhook node on the false branch.

4. process.env becomes credentials

15 workflows read process.env. process is not defined in the Code node, and since n8n 2.0 N8N_BLOCK_ENV_ACCESS_IN_NODE defaults to true, so $env is blocked too.

Code nodes can't read n8n credentials, by design. So the fix is structural: move the API call into an HTTP Request node (or the app node) and attach a credential there. Keep the Code node for logic only.

// Pipedream
const res = await axios.get("https://api.stripe.com/v1/charges", {
  headers: { Authorization: `Bearer ${process.env.STRIPE_KEY}` },
});
Enter fullscreen mode Exit fullscreen mode

In n8n: an HTTP Request node, GET https://api.stripe.com/v1/charges, Authentication set to a Header Auth or the Stripe credential. The key never appears in the workflow.

The tempting shortcut is an Edit Fields (Set) node with STRIPE_KEY as a field, read by the Code node. Don't. A node parameter is saved in the workflow JSON, so it goes into every export and into Git if you use source control. The field is also part of the node output, and n8n saves execution data for successful and failed runs by default (EXECUTIONS_DATA_SAVE_ON_SUCCESS=all), kept for 14 days by default. Anyone who opens an execution sees the key.

For non-secret config, $vars.KEY works in code and expressions, but only on Cloud Pro/Enterprise and self-hosted Business/Enterprise.

5. npm packages and Node built-ins

29 workflows import npm packages, snowflake-sdk in 15 of them. 13 workflows stopped on "Module ... is disallowed": snowflake-sdk, crypto, date-fns, fast-xml-parser, @atproto/api and others.

Three things change:

  • No ES import. Every import x from "pkg" becomes const x = require('pkg').
  • n8n Cloud loads only crypto and moment. Anything else means a rewrite, an n8n node, or self-hosting.
  • Self-hosted needs the package installed in the runner image and allow-listed. With task runners, the allow-list goes on the runners, not on the main n8n container.
FROM n8nio/runners:<same version as your n8nio/n8n>
USER root
RUN cd /opt/runners/task-runner-javascript && pnpm add date-fns fast-xml-parser
COPY n8n-task-runners.json /etc/n8n-task-runners.json
USER runner
Enter fullscreen mode Exit fullscreen mode

In n8n-task-runners.json, under the javascript runner's env-overrides, set NODE_FUNCTION_ALLOW_BUILTIN (for example crypto) and NODE_FUNCTION_ALLOW_EXTERNAL (for example moment,date-fns,fast-xml-parser). Start from the default file in the image and change only these keys. In my plain-image run, even require('crypto') and require('lodash') failed until allowed.

6. Python steps

There are 42 Python steps in 21 workflows, and 16 workflows stopped because the plain n8n image has no Python. Since n8n 2.0, the Python Code node runs only with task runners in external mode. That means a second container from n8nio/runners (same version as n8n), with N8N_RUNNERS_MODE=external and a shared N8N_RUNNERS_AUTH_TOKEN on n8n, and N8N_RUNNERS_TASK_BROKER_URI pointing at n8n on the runner.

The handler shape changes too. Native Python only gets _items (all-items mode) or _item (each-item mode), with bracket access:

# Pipedream
def handler(pd: "pipedream"):
    body = pd.steps["trigger"]["event"]["body"]
    if not body.get("email"):
        return pd.flow.exit("no email")
    return {"email": body["email"].lower()}
Enter fullscreen mode Exit fullscreen mode
# n8n Code node, Python, Run Once for All Items
out = []
for item in _items:
    body = item["json"]["body"]
    if body.get("email"):
        out.append({"json": {"email": body["email"].lower()}})
return out
Enter fullscreen mode Exit fullscreen mode

Imports follow the same pattern as npm: uv pip install in /opt/runners/task-runner-python, then N8N_RUNNERS_STDLIB_ALLOW and N8N_RUNNERS_EXTERNAL_ALLOW in the python runner's env-overrides. On n8n Cloud, the Python Code node can't import libraries at all.

7. Audit your export before you migrate

Unzip your Pipedream export (or clone your GitHub Sync repo) and count what you will have to touch:

grep -rEoh "fetch\(|axios|AbortController|new URL\(|URLSearchParams|process\.env\.[A-Za-z_]+|\\\$\.respond|\\\$\.flow\.[a-z]+|from ['\"][^'\"./][^'\"]*['\"]|require\(['\"][^'\"]+['\"]\)|/tmp/|def handler" ./pipedream-export | sort | uniq -c | sort -rn
Enter fullscreen mode Exit fullscreen mode

It is a rough count and fetch( also catches method calls, but it tells you quickly whether you are looking at an afternoon or a week. Add -l instead of -oh to see which files are affected. To see what your own instance allows, import the small probe workflow from the same GitHub repo.

What I would do first

Python and npm decide whether n8n Cloud is even an option, so check those first. Then move secrets into credentials and HTTP calls into HTTP Request nodes. The $.export and $.flow.exit changes are mechanical after that.

Top comments (0)