Back to Nodes

KeyValue

v0.6.0
Last updated Jun 30, 2026

n8n node for storing key-value pairs on the filesystem

1,318 Weekly Downloads
1,568 Monthly Downloads

Included Nodes

KeyValue
KeyValue Trigger

Description

n8n-nodes-keyvalue

A filesystem-based key-value store and trigger node for n8n. Store, retrieve, list, and delete key-value pairs using plain directories and text files. Includes full-text search, vault navigation, tag management, and YAML frontmatter metadata. Also includes a trigger node that watches directories for new or changed records.

~/.n8n-keyvalue/          ← your store root
├── customers/            ← a directory
│   ├── alice             ← a record (text file)
│   └── bob
└── orders/
    ├── order_001
    └── order_002

Installation

Install from the n8n community nodes panel, or:

npm install n8n-nodes-keyvalue

No external dependencies — uses only Node.js built-in modules (fs, path, os).

> Note: This node operates on the local filesystem and cannot run on n8n Cloud.

Quick Start

1. Add a KeyValue node to your workflow.
2. Select Directory → Create, enter my_dir.
3. Add another KeyValue node, select Record → Write, enter directory my_dir, key greeting, value Hello n8n!.
4. Add a third node, Record → Read, same directory and key — outputs Hello n8n!.

Operations Reference

Counter Resource

| Operation | Fields | Description | Output |
|———–|——–|————-|——–|
| Get | Counter Name (required) | Reads the current counter value | { "counter": "name", "value": 42 } |
| Increment | Counter Name, Increment By, Start At | Increments a counter and returns the new value. Creates it at startAt on first call | { "counter": "name", "value": 43 } |
| Reset | Counter Name, Reset To | Resets a counter to a target value | { "counter": "name", "value": 0 } |

  • Increment By (default 1) — amount to add each call.
  • Start At (default 0) — value to create the counter at if it doesn’t exist yet. Ignored once the counter exists.
  • Reset To (default 0) — value to set the counter to on reset.
  • Counters are stored as plain text files under counters/ in the base directory.
  • Get and Reset on a non-existent counter throw an error.
  • Directory Resource

    | Operation | Field | Description | Output |
    |———–|——-|————-|——–|
    | Create | Directory Name (required) | Creates a new directory | { "directory": "name", "created": true } |
    | Delete | Directory Name (required) | Deletes a directory and all its records | { "directory": "name", "deleted": true } |
    | List | — | Lists all directories | [{ "directory": "name1" }, { "directory": "name2" }] |

    Record Resource

    | Operation | Fields | Description | Output |
    |———–|——–|————-|——–|
    | Append | Directory Name, Key, Value, Separator | Appends value to a record (creates if missing) | { "directory": "name", "key": "k", "value": "...", "appended": true } |
    | Count | Directory Name, Key Filter, Tag Filter | Counts records with optional glob and tag filters | { "directory": "name", "count": 5 } |
    | Delete | Directory Name, Key Filter, Value Filter, Tag Filter | Deletes records matching filters. At least one filter required. Use “*” for all records | [{ "directory": "name", "key": "k", "deleted": true }] |
    | Exists | Directory Name, Key | Checks if a record exists without throwing an error | { "directory": "name", "key": "k", "exists": true } |
    | List | Directory Name, Key Filter, Value Filter, Tag Filter, Include Frontmatter | Lists records with optional glob, content, and tag filters | [{ "directory": "name", "key": "k", "value": "..." }] |
    | Read | Directory Name, Key, Mode | Reads a record. Mode: Full (default) / Frontmatter Only / Body Only. JSON auto-parsed | { "directory": "name", "key": "k", "value": "...", "frontmatter": {...}, "body": "..." } |
    | Touch | Directory Name, Key | Updates mtime. Creates empty record if key does not exist | { "directory": "name", "key": "k", "touched": true, "created": false } |
    | Write | Directory Name, Key, Value, Frontmatter (collection) | Creates/overwrites. Optional YAML frontmatter metadata | { "directory": "name", "key": "k", "value": "...", "written": true } |

    List Filters

  • Directory Filter (Directory → List) — glob pattern on directory names. matches any characters, ? matches one. Example: prod matches prodeu, prod_us.
  • Key Filter (Record → List, Count, Delete) — glob pattern on filename. matches any characters, ? matches one. Example: user matches useralice, user_bob.
  • Value Filter (Record → List, Delete) — substring match inside file content. Example: active returns only records containing “active”.
  • Tag Filter (Record → List, Count, Delete) — exact match on a frontmatter tag. Only matches records whose tags array contains this value. Reads file content when enabled.
  • All filters combined use AND logic. Empty = match all.
  • Frontmatter (YAML Metadata)

    Records can optionally include YAML frontmatter — structured metadata stored as --- delimited blocks at the top of the file:

    ---
    tags: [n8n, error-handling, critical]
    description: Error format rules for n8n nodes
    updated: "2026-06-29T14:30:00Z"
    ---
    Error must be at top level of INodeExecutionData.
    

    Write with frontmatter: expand the Frontmatter collection on Record → Write. Fields: Tags (comma-separated), Description, Updated (ISO timestamp — auto-filled if empty).

    Read modes: Record → Read offers three modes via the Mode dropdown:

  • Full (default) — returns value, frontmatter, and body
  • Frontmatter Only — returns only frontmatter metadata (no content I/O for body)
  • Body Only — returns only value (backward compatible)
  • List with frontmatter: enable Include Frontmatter to return frontmatter per item. Use values via {{ $json.frontmatter.tags }}.

    The value field is always the clean content (body), never polluted by YAML metadata. Files without frontmatter return frontmatter: null — fully backward compatible.

    Append Separator

    The Separator field on Record → Append defaults to a newline. You can type \n for a newline, use , for CSV-style, or leave empty for direct concatenation.

    KeyValue Trigger Node

    The KeyValue Trigger node watches a directory for new or changed records and triggers your workflow when changes are detected. Place it at the start of a workflow — it requires no upstream input.

    Trigger Operations

    | Field | Type | Required | Default | Description |
    |——-|——|———-|———|————-|
    | Directory Name | string | ✅ | '' | Directory to watch for changes |
    | Poll Time | number | ❌ | 30 | Interval in seconds between each scan. Minimum: 5 seconds |
    | Watch Events | multiOptions | ✅ | ['add', 'change'] | File Created — new record files ; File Changed — modified record files |
    | Key Filter | string | ❌ | '' | Glob pattern to filter records by filename. Leave empty to match all |
    | Include Content | boolean | ❌ | true | Read and return the content of detected records. Disable for faster scanning |

    Trigger Output

    Each detected file produces one item:

    {
      "directory": "incoming",
      "key": "order_001.json",
      "event": "add",
      "value": { "id": 1, "total": 99.99 },
      "mtimeMs": 1719403200000
    }
    

    When no changes are detected, the scan is silent (no execution).

    Trigger Quick Start

    1. Add a KeyValue Trigger node to a new workflow.
    2. Set Directory Name to my_queue, Poll Time to 15.
    3. Execute the workflow — it activates and waits.
    4. In a separate workflow, use KeyValue (Record → Write) to create a file in my_queue/.
    5. Within 15 seconds, the trigger workflow fires with event: "add".

    Trigger Examples

    KeyValue Trigger (directory: "orders", Poll Time: 10, events: add + change)
      → IF ({{ $json.event }} = "add")
        → HTTP Request (POST to fulfillment API)
    
    KeyValue Trigger (directory: "logs", Key Filter: "error_*", Include Content: true)
      → Email (send alert with file content)
    

    Polling Limitation

    The trigger uses time-based polling (setInterval). It compares file modification timestamps (mtime) between scans, not individual write operations:

  • Rapid updates (multiple writes to the same file between two scans) are collapsed into a single change event.
  • Example: 10 writes in 3 seconds with Poll Time: 15 → at most 1-2 events captured.
  • Solution: reduce Poll Time (minimum 5s) for high-frequency use cases.
  • No event history: the trigger only sees “the file changed since last scan”, not “how many times”.
  • State File

    The trigger stores its state in .keyvalue-watch-state.json inside the watched directory:

    { "filename": 1719403200000, ... }
    

    It tracks mtimeMs for each file. This file is overwritten on every scan and visible in Record → List. Deleting it resets the trigger to a clean baseline (no existing files will fire events on restart).

    Examples

    Counter: basic counting

    KeyValue (Counter → Increment, name: "page_visits")
      → first call returns { "counter": "page_visits", "value": 1 }
      → second call returns { "counter": "page_visits", "value": 2 }
    
    KeyValue (Counter → Reset, name: "page_visits")
      → returns { "counter": "page_visits", "value": 0 }
    

    Counter: starting at a custom value

    KeyValue (Counter → Increment, name: "invoice", Start At: 1000)
      → first call returns { "counter": "invoice", "value": 1001 }
      → second call returns { "counter": "invoice", "value": 1002 }
    

    Create a directory and write a record

    KeyValue (Directory → Create, name: "config")
      → KeyValue (Record → Write, directory: "config", key: "api_url", value: "https://api.example.com")
    

    List all directories, then delete one

    KeyValue (Directory → List)
      → KeyValue (Directory → Delete, name: "old_dir")
    

    Filter records by pattern

    KeyValue (Record → List, directory: "users", Key Filter: "admin_*")
      → returns only keys starting with "admin_"
    

    Count records in a directory

    KeyValue (Record → Count, directory: "users", Key Filter: "admin_*")
      → returns { "directory": "users", "count": 3 }
    

    Delete records by filter

    Delete a single record by exact key

    KeyValue (Record → Delete, directory: "cache", Key Filter: "old_report")

    Delete all records matching a pattern

    KeyValue (Record → Delete, directory: "cache", Key Filter: "temp_*")

    Delete all records (explicit — must type "*")

    KeyValue (Record → Delete, directory: "cache", Key Filter: "*")

    Delete records containing "expired" in content

    KeyValue (Record → Delete, directory: "cache", Value Filter: "expired")

    At least one filter is required. Leaving both empty throws an error — this is a safety guard.

    Conditional branching with Exists

    KeyValue (Record → Exists, directory: "cache", key: "latest_report")
      → returns { "directory": "cache", "key": "latest_report", "exists": true }
      → use {{ $json.exists }} in an IF node to branch
    

    Heartbeat / liveness check with Touch

    Cron (every 5 min) → KeyValue (Record → Touch, directory: "health", key: "worker_alive")
      → returns { "directory": "health", "key": "worker_alive", "touched": true }

    Monitor workflow → KeyValue (Record → Stat / Read, directory: "health", key: "worker_alive") → check mtime or use a KeyValue Trigger on "health/" with Event: File Changed → if no touch in 10 minutes → alert

    Touch creates the record on first call (created: true), then only updates timestamps on subsequent calls (touched: true). The KeyValue Trigger treats Touch as a change event.

    JSON auto-detection

    When you write a record whose value is a valid JSON object or array ({...} / [...]), it is stored as structured JSON and automatically parsed back on Read and List:

    KeyValue (Record → Write, directory: "contacts", key: "jean",
      value: {"prenom": "Jean", "nom": "Dupont", "telephone": "+33 6 12 34 56 78"})
      → stored as formatted JSON on disk

    KeyValue (Record → Read, directory: "contacts", key: "jean") → returns { "prenom": "Jean", "nom": "Dupont", ... } → use {{ $json.value.prenom }} in downstream nodes

    KeyValue (Record → Write, directory: "contacts", key: "jean_copy", value: {{ $json.value }}) → the parsed array/object is passed directly and stored as JSON

    Plain text, numbers, and booleans are stored as-is (strings). Only {...} and [...] trigger JSON mode. The Append operation always treats values as plain text.

    Search Resource

    | Operation | Fields | Description | Output |
    |———–|——–|————-|——–|
    | Query | Query (required), Directory Filter, Tag Filter, Limit, Include Snippets | Full-text search across all files (key + body). AND logic, case-insensitive, scored | [{ "directory": "d", "key": "k", "score": 4, "tags": [...], "snippet": "..." }] |

  • Query — list of words, separated by spaces. All words must match (AND logic).
  • Scoring — number of occurrences summed across all terms. Sorted by score descending.
  • Search scope — both the filename (key) and content (body) are searched.
  • Snippets — ±60 characters of context around the first match. Shows key: when the match is in the key. Disable with Include Snippets: false for faster queries.
  • Vault Resource

    | Operation | Fields | Description | Output |
    |———–|——–|————-|——–|
    | Backlinks | Target Path (required), Directory Filter | Finds files that reference a target file (exact path, wiki link [[...]], or bare filename) | [{ "sourcedirectory": "...", "sourcekey": "...", "matched_pattern": "[[...]]", "line": N, "context": "..." }] |
    | Recent | Limit, Directory Filter, Tag Filter | Lists recently modified files sorted by mtime | [{ "directory": "d", "key": "k", "updated": "ISO", "size": N }] |
    | Stats | — | Aggregate vault statistics | { "files": N, "directories": N, "totalsizekb": N, "uniquetags": N, "lastmodified": "...", "lastmodifiedat": "ISO" } |
    | Tree | Path, Max Depth | Displays the vault directory structure as an ASCII tree | { "tree": "db1/n├── file1n└── file2", "directories": [...], "files": [...] } |

    Tag Resource

    | Operation | Fields | Description | Output |
    |———–|——–|————-|——–|
    | List | Directory Filter | Lists all unique tags across the vault with file counts | [{ "tag": "n8n", "count": 12, "files": ["dir1/file1", ...] }] |

    Data Model

    | n8n concept | Filesystem |
    |————-|————|
    | Counter | Plain text file under counters/ containing a number |
    | Directory | Subdirectory under ~/.n8n-keyvalue/ |
    | Record (key-value pair) | File whose name is the key, contents are the value |
    | Delete (filter-based) | Scans directory, applies Key Filter (glob), Value Filter (substring), and/or Tag Filter (exact tag). At least one filter required |
    | Value | Plain UTF-8 text stored in the file. JSON objects/arrays ({…} / […]) are auto-detected on Write and auto-parsed on Read |
    | Count | Lightweight tally of records — reads file contents only when Tag Filter is enabled |
    | Exists | fs.existsSync check — no file contents read, never throws |
    | Touch | Updates record mtime only — no content I/O on existing records. Creates an empty record if the key does not exist |
    | Frontmatter | YAML metadata block at the top of the file (--- delimited). Contains tags, description, updated. Stripped from value on read. |
    | Search | Full-text scan of all files (key + body). Tokenized AND search with occurrence scoring and optional snippet extraction |
    | Vault | Cross-directory navigation: Tree (ASCII), Stats (aggregates), Recent (by mtime), Backlinks (cross-references) |
    | Tag | Index of all unique frontmatter tags with per-file counts |
    | Trigger | Polling-based watcher on a directory. Detects add/change via mtime comparison between scans |

    KeyValue Use Cases

    When building robust automation in n8n, certain heavy lifting, like initializing an environment or creating a specific SQL table, should only happen exactly once. By pairing the n8n-nodes-keyvalue community node with the built-in $workflow.id variable, you can seamlessly track initialization states across executions without complex error-handling.

    !Run-once initialization workflow

    Environment Variables

    | Variable | Default | Description |
    |———-|———|————-|
    | N8NKEYVALUEDIR | ~/.n8n-keyvalue | Override the base directory. Useful for Docker / persistent volume mounts. |

    Setting up a persistent directory (Docker)

    If your n8n container has a persistent volume at /home/node/.n8n, set up KeyValue inside it:

    Inside the container (or add to your Dockerfile/entrypoint)

    mkdir -p /home/node/.n8n/keyvalue

    Then set the environment variable on your n8n container:

    N8NKEYVALUEDIR=/home/node/.n8n/keyvalue
    

    All directories and records will now be stored under /home/node/.n8n/keyvalue/ and survive container restarts.

    > Security note: If your n8n instance has N8NRESTRICTFILEACCESSTO set, include the KeyValue directory in its semicolon-separated list:
    >

    > N8NKEYVALUEDIR=/home/node/.n8n/keyvalue
    > N8NRESTRICTFILEACCESSTO=/home/node/.n8n
    > 

    Limitations

  • No input validation: Directory names and record keys are passed directly to the filesystem. Invalid characters (/, <, >, :, ", , |, ?, *) will cause OS-level errors. Stick to alphanumeric names, hyphens, and underscores for now.

Development

git clone https://github.com/stephaneheckel/n8n-nodes-keyvalue.git
cd n8n-nodes-keyvalue
npm install --legacy-peer-deps
npm run build
npm run dev        # starts n8n with hot-reload at localhost:5678

Hermes Agent External Memory

This node powers an external memory system for Hermes Agent. The KeyValue store at ~/.n8n-keyvalue/ holds structured markdown files with YAML frontmatter in a single reserved directory:

| Directory | Purpose |
|-----------|---------|
| memory/ | Hermes external memory — conventions, pitfalls, projects, user context |

Taxonomy is encoded in YAML tags, not subdirectories. A single search finds everything:
search_files("n8n", path="~/.n8n-keyvalue/memory/").

Hermes accesses these files via readfile/searchfiles, and n8n workflows can navigate them via Search, Record, Vault, and Tag resources. Full documentation: EXTERNAL-MEMORY-SYSTEM.md

License

MIT