$npx -y skills add czlonkowski/n8n-skills --skill n8n-binary-and-dataHandle files and binary data in n8n correctly. Use when working with files, images, PDFs, attachments, uploads or downloads, base64, vision/multimodal input, or when an AI agent needs a file as tool input or output — and whenever the user mentions $binary, binaryPropertyName, "re
| 1 | # n8n Binary and Data |
| 2 | |
| 3 | Every n8n item carries two independent slots: `$json` for structured data and `$binary` for file bytes. They travel side by side through the workflow. File contents — the actual PDF, image, or zip — live in `$binary`, never in `$json`. Get that split wrong and you read an empty field, lose a file mid-flow, or hand an AI agent a tool input it can't use. |
| 4 | |
| 5 | This skill covers where binary lives, how to read and write it, how to keep it from being silently stripped, the hard wall between binary and the AI-agent tool boundary, and why chat surfaces need a URL instead of raw bytes. |
| 6 | |
| 7 | --- |
| 8 | |
| 9 | ## The three rules that prevent 90% of binary bugs |
| 10 | |
| 11 | 1. **File contents are in `$binary`, not `$json`.** After an HTTP download, a "Read Files", or an email-attachment trigger, the bytes sit in `$binary.<key>`. `$json` holds metadata at most. Reading `$json.data` for file contents gives you nothing. |
| 12 | |
| 13 | 2. **Binary cannot cross the AI-agent tool boundary — in either direction.** Tool arguments and tool return values are JSON only. An uploaded image can't be passed into a tool as a file, and a tool can't return raw bytes. Pre-stage to storage and pass a key or URL through JSON instead. See `AGENT_TOOL_BINARY.md`. |
| 14 | |
| 15 | 3. **Chat surfaces render images by URL, not by `$binary`.** Slack, Discord, Teams, Telegram, embedded webhook chat — none of them read the binary slot. The image has to live somewhere a URL can fetch it. See `CDN_REQUIREMENT.md`. |
| 16 | |
| 17 | --- |
| 18 | |
| 19 | ## The two slots |
| 20 | |
| 21 | Each item is shaped like this: |
| 22 | |
| 23 | ```json |
| 24 | { |
| 25 | "json": { "customerId": 42, "status": "sent" }, |
| 26 | "binary": { |
| 27 | "invoice": { |
| 28 | "data": "<base64-encoded bytes>", |
| 29 | "mimeType": "application/pdf", |
| 30 | "fileName": "invoice-42.pdf", |
| 31 | "fileExtension": "pdf" |
| 32 | } |
| 33 | } |
| 34 | } |
| 35 | ``` |
| 36 | |
| 37 | The key inside `binary` (`invoice` here) is the **binary property name**. Most file-handling nodes have a `binaryPropertyName` parameter that points at it — the producer names the slot, the consumer references it by that name. The default key across most nodes is `data`, so when nothing tells you otherwise, assume `$binary.data`. |
| 38 | |
| 39 | `$json` and `$binary` are separate namespaces. An expression like `{{ $binary.invoice.fileName }}` reads file metadata; `{{ $json.customerId }}` reads data. They never mix. |
| 40 | |
| 41 | This split also explains a webhook gotcha: a Webhook trigger receiving `multipart/form-data` puts the uploaded file in `$binary` and the accompanying form fields in `$json.body` — so an uploaded file is not somewhere under `$json` at all. (The `$json.body` nesting for webhooks is **n8n-expression-syntax** territory.) |
| 42 | |
| 43 | See `BINARY_BASICS.md` for the full slot anatomy, mime types, and size limits. |
| 44 | |
| 45 | --- |
| 46 | |
| 47 | ## Producing binary |
| 48 | |
| 49 | You rarely build a `$binary` slot by hand — nodes populate it for you: |
| 50 | |
| 51 | | Source | How binary appears | |
| 52 | |---|---| |
| 53 | | HTTP Request with `responseFormat: "file"` | Response body lands in `$binary.data` (or the name you set) | |
| 54 | | Read/Write Files from Disk | File contents read into `$binary` | |
| 55 | | Storage downloads (S3, Google Drive, Dropbox, etc.) | Downloaded file in `$binary.<key>` | |
| 56 | | Email triggers with attachments | Each attachment arrives in `$binary` | |
| 57 | | Provider AI media nodes (image/audio gen) | Set `options.binaryPropertyOutput` so the bytes land where the next node looks | |
| 58 | |
| 59 | For an HTTP download, the one field that matters is `responseFormat`. Confirm it with `get_node` on `nodes-base.httpRequest` — leaving it as the default JSON/string format is the classic reason a downloaded file ends up as garbled text in `$json` instead of clean bytes in `$binary`. |
| 60 | |
| 61 | --- |
| 62 | |
| 63 | ## Reading and writing binary in a Code node |
| 64 | |
| 65 | Most workflows never need to crack open the bytes — they just pass binary through to a consumer (email attachment, file upload, Slack file). When you do need the raw bytes, do it in a Code node. |
| 66 | |
| 67 | **Read** with `getBinaryDataBuffer` — do not try to base64-decode `$binary.<key>.data` by hand: |
| 68 | |
| 69 | ```javascript |
| 70 | // Code node, "Run Once for Each Item" |
| 71 | const buffer = await this.helpers.getBinaryDataBuffer(0, 'data'); // (itemIndex, propertyName) |
| 72 | const text = buffer.toString('utf-8'); |
| 73 | const length = buffer.length; |
| 74 | |
| 75 | return [{ |
| 76 | json: { ...$json, length }, |
| 77 | binary: $input.item.binary, // pass the binary through, or it's gone |
| 78 | }]; |
| 79 | ``` |
| 80 | |
| 81 | **Write** by building the slot yourself — base64 the bytes plus a mime type and file name: |
| 82 | |
| 83 | ```javascript |
| 84 | const text = 'Hello, world!' |