# How to Fix Variable Scoping Issues with User Context in n8n Prompts

- Tool: n8n
- Difficulty: Advanced
- Time required: 25-35 minutes
- Compatibility: n8n 1.20+ (self-hosted and Cloud)
- Last updated: March 2026

## TL;DR

Variable scoping issues in n8n prompts occur when data from upstream nodes is inaccessible in the LLM node because of node execution order, parallel branches, or item index mismatches. Fix this by using the $('NodeName') reference syntax, merging parallel branches before the LLM node, and building a consolidated context object in a Code node that collects all user data into a single output item for the prompt.

## Passing Variables Correctly Through Nodes to LLM Prompts in n8n

You have user context data from a database query, webhook payload, and previous conversation history that all need to reach the LLM prompt. But some variables show up as undefined, others reference the wrong item, and data from parallel branches is completely invisible. These are variable scoping issues: the LLM node cannot access data that exists in the workflow but is not in its direct data path. This tutorial explains how data flows through n8n, why certain variables are out of scope, and how to collect all necessary context into a single accessible object.

## Before you start

- A running n8n workflow with multiple data sources feeding into an LLM node
- Understanding of n8n's node connection model and data flow
- Basic familiarity with n8n expressions ({{ }} syntax)
- An AI Agent or Basic LLM Chain node in the workflow

## Step-by-step guide

### 1. Understand how data scoping works in n8n

In n8n, each node receives data from its directly connected upstream node. The data is an array of items, each with a json property containing the node's output. The expression $json refers to the first item from the immediately previous node. To access data from a node further upstream, you must use the $('NodeName') syntax which looks up the output of any executed node by name. Critically, $('NodeName') only works for nodes that are in the same execution branch and have already executed. If a node is on a parallel branch that has not been merged, its output is inaccessible. Understanding this scoping model is essential for building prompts that require data from multiple sources.

```
// $json = first item from immediately previous node
// $input.all() = all items from immediately previous node
// $('Webhook').first().json = first item from the 'Webhook' node
// $('Database Query').all() = all items from the 'Database Query' node
// $('Set User Data').first().json.name = specific field from a named node
```

**Expected result:** You understand that data access depends on node execution order and branch connectivity.

### 2. Identify which variables are out of scope in your LLM prompt

Open the LLM node (AI Agent or Basic LLM Chain) and click on the expression editor for the prompt or system message field. Use the variable selector on the left panel to browse available data from upstream nodes. Nodes that appear in the selector are in scope; those that are missing are either on an unmerged parallel branch or have not been connected upstream. For each variable in your prompt that shows 'undefined', check whether the source node appears in the variable selector. If it does not, you have a scoping issue. Note which nodes are missing and trace the workflow connections to understand why they are out of scope.

**Expected result:** You have a list of which variables are accessible and which are out of scope in the LLM prompt.

### 3. Merge parallel branch data using a Merge node

If your user context data comes from parallel branches (e.g., one branch fetches user profile from a database while another fetches conversation history from Redis), these branches must be merged before the data reaches the LLM node. Add a Merge node that combines both branches. Use the Combine mode with Merge by Position to create a single item that contains data from both branches. Then use a Code node after the Merge to combine the fields from both sources into a clean context object. Without the Merge, the LLM node only sees data from whichever branch is directly connected to it.

```
// Code node after Merge: Combine context from both branches
// Mode: Run Once for All Items

const items = $input.all();

// After Merge by Position, each item has fields from both branches
const merged = items[0]?.json || {};

// Build consolidated context
const context = {
  // From database branch
  userName: merged.name || merged.userName || 'User',
  email: merged.email || '',
  plan: merged.subscriptionPlan || 'free',
  
  // From conversation history branch
  previousMessages: merged.messages || merged.history || [],
  lastInteraction: merged.lastMessageAt || null,
  
  // From webhook (original trigger)
  currentMessage: merged.message || merged.text || '',
  sessionId: merged.sessionId || ''
};

return [{ json: context }];
```

**Expected result:** Data from all parallel branches is combined into a single item accessible by the LLM node.

### 4. Build a consolidated context object in a Code node

The most reliable approach is to add a Code node immediately before the LLM node that collects all necessary data into a single context object. This node uses the $('NodeName') syntax to reach back to any upstream node, regardless of how many nodes are between them. It then outputs a single item with all fields the LLM prompt needs. The LLM node can then reference any field with simple $json.fieldName expressions. This pattern centralizes all data access in one place, making it easy to debug and maintain.

```
// Code node: Context Collector
// Mode: Run Once for All Items

// Reach back to specific upstream nodes
const webhookData = $('Webhook').first().json;
const userData = $('Fetch User Profile').first().json;
const historyData = $('Load Chat History').all();

// Build the context object
const context = {
  // User identity
  chatInput: webhookData.body?.message || '',
  sessionId: webhookData.body?.sessionId || '',
  
  // User profile
  userName: userData.name || 'User',
  userPlan: userData.plan || 'free',
  userTimezone: userData.timezone || 'UTC',
  
  // Conversation history (last 10 messages)
  conversationHistory: historyData
    .slice(-10)
    .map(item => ({
      role: item.json.role,
      content: item.json.content
    })),
  
  // Dynamic system prompt additions
  systemContext: `The user's name is ${userData.name || 'User'}. `
    + `They are on the ${userData.plan || 'free'} plan. `
    + `Their timezone is ${userData.timezone || 'UTC'}.`
};

return [{ json: context }];
```

**Expected result:** A single item containing all user context data, accessible from the LLM node with simple $json references.

### 5. Handle multi-item data when the LLM expects a single input

When an upstream node outputs multiple items (e.g., a database query returning multiple rows), the LLM node processes each item separately by default. If you need all items combined into a single prompt (e.g., a list of user orders to summarize), you must aggregate them first. Use a Code node set to Run Once for All Items that collects all items and combines them into a single output item. Format arrays as readable text for the prompt: convert rows to a numbered list or a formatted table. Do not pass raw JSON arrays to the LLM prompt unless you specifically want JSON output.

```
// Code node: Aggregate multiple items into single prompt context
// Mode: Run Once for All Items

const items = $input.all();

// Example: Convert order records into a readable list
const orders = items.map((item, i) => {
  const o = item.json;
  return `${i + 1}. Order #${o.orderId} - ${o.product} - $${o.amount} - ${o.status}`;
});

const orderSummary = orders.length > 0
  ? `The user has ${orders.length} recent orders:\n${orders.join('\n')}`
  : 'The user has no recent orders.';

return [{
  json: {
    orderContext: orderSummary,
    totalOrders: orders.length
  }
}];
```

**Expected result:** Multiple data items are aggregated into a single formatted text that the LLM can process as part of the prompt.

### 6. Pass the consolidated context to the LLM prompt using simple expressions

With the context object built in the previous step, configure your LLM node to reference it with straightforward expressions. In the AI Agent's system message, use {{ $json.systemContext }}. In the prompt or chatInput field, use {{ $json.chatInput }}. If you need to inject the order summary into the system prompt, include {{ $json.orderContext }} in the system message template. Because the Code node outputs a single item with all fields at the top level, every expression is a simple $json.fieldName lookup with no complex paths or node name references needed. This makes the LLM node configuration clean and easy to maintain.

```
// AI Agent node configuration:

// System Message field:
// {{ $json.systemContext }}
// 
// The user's recent order history:
// {{ $json.orderContext }}

// Prompt / chatInput field:
// {{ $json.chatInput }}

// Session ID (in Memory sub-node):
// {{ $json.sessionId }}
```

**Expected result:** The LLM receives a complete prompt with all user context variables correctly populated.

## Complete code example

File: `context-collector.js`

```javascript
// Code node: Production Context Collector
// Mode: Run Once for All Items
// Place immediately before the AI Agent or LLM Chain node
// Collects data from ALL upstream nodes into a single object

function safeGet(nodeName) {
  try {
    return $('"' + nodeName + '"').first().json;
  } catch (e) {
    console.log(`Node "${nodeName}" not found or has no data`);
    return {};
  }
}

function safeGetAll(nodeName) {
  try {
    return $('"' + nodeName + '"').all().map(i => i.json);
  } catch (e) {
    console.log(`Node "${nodeName}" not found or has no data`);
    return [];
  }
}

// Collect from all sources
const webhook = $('Webhook').first().json;
const body = webhook.body || webhook;

// User profile (from database or API)
let userProfile = {};
try {
  userProfile = $('Fetch User Profile').first().json;
} catch (e) {
  console.log('User profile node not found, using defaults');
}

// Conversation history
let history = [];
try {
  history = $('Load Chat History').all().map(i => i.json);
} catch (e) {
  console.log('Chat history node not found');
}

// Build the consolidated context
const context = {
  // Core input
  chatInput: body.message || body.text || body.content || '',
  sessionId: body.sessionId || body.userId || 'default',

  // User profile context
  userName: userProfile.name || body.userName || 'User',
  userEmail: userProfile.email || '',
  userPlan: userProfile.plan || 'free',
  userLocale: userProfile.locale || 'en',

  // Formatted history for system prompt
  conversationSummary: history.length > 0
    ? `Previous conversation (${history.length} messages):\n`
      + history.slice(-5).map(
        m => `${m.role}: ${(m.content || '').substring(0, 200)}`
      ).join('\n')
    : 'No previous conversation.',

  // System prompt with injected context
  systemContext: [
    `User: ${userProfile.name || 'Unknown'}`,
    `Plan: ${userProfile.plan || 'free'}`,
    `Language: ${userProfile.locale || 'en'}`,
    history.length > 0
      ? `This is message #${history.length + 1} in the conversation.`
      : 'This is the start of a new conversation.'
  ].join('\n'),

  // Metadata for logging
  _meta: {
    timestamp: new Date().toISOString(),
    executionId: $execution.id,
    sourcesAvailable: {
      webhook: true,
      userProfile: Object.keys(userProfile).length > 0,
      history: history.length > 0
    }
  }
};

return [{ json: context }];
```

## Common mistakes

- **Using $json to access data from a node that is not the immediately previous one** — undefined Fix: Use $('NodeName').first().json to reference any upstream node by name, regardless of position in the pipeline.
- **Not merging parallel branches before the LLM node, making one branch's data invisible** — undefined Fix: Add a Merge node to combine parallel branches into a single data stream before the LLM node.
- **Passing an array of items to the LLM node when it expects a single prompt string** — undefined Fix: Add a Code node that aggregates multiple items into a single formatted text string.
- **Using hardcoded node names in expressions that break when nodes are renamed** — undefined Fix: Use descriptive, stable node names and avoid renaming nodes after building expressions that reference them.
- **Not handling the case where an upstream node has no output data** — undefined Fix: Wrap $('NodeName') calls in try-catch blocks and provide defaults when the node data is unavailable.

## Best practices

- Use a dedicated Context Collector Code node before the LLM to centralize all data access in one place
- Reference upstream nodes by name using $('NodeName') instead of relying on $json which only sees the previous node
- Merge parallel branches with a Merge node before data needs to reach the LLM node
- Aggregate multi-item data into a single formatted text before passing to the LLM prompt
- Rename nodes to descriptive names to make $('NodeName') references self-documenting
- Always provide fallback defaults for optional context fields to prevent 'undefined' in prompts
- Keep the context object flat (no deep nesting) so LLM node expressions are simple $json.fieldName references
- Log which data sources were available in the context object for debugging missing data

## Frequently asked questions

### Why does $json.userName show undefined even though the Webhook has the data?

$json references the immediately previous node, not the Webhook. If there are other nodes between the Webhook and your current node, use $('Webhook').first().json.body.userName to reference the Webhook directly.

### Can I access data from a node on a different branch?

No, you can only access nodes that are in your direct upstream path. If data comes from a parallel branch, add a Merge node to bring both branches together before the node that needs the data.

### What does $input.all() return vs $json?

$json is shorthand for $input.first().json and returns the JSON data of the first item. $input.all() returns an array of all items from the previous node, each wrapped in { json: {...} }.

### How do I pass 50 database rows to an LLM prompt?

Use a Code node to aggregate the rows into a formatted text string. Convert each row to a readable line and join them. Then pass the formatted string as a single field that the LLM prompt references.

### Can I use $('NodeName') in the AI Agent's system message field?

Yes, $('NodeName') works in any expression field. However, for complex prompts with multiple references, build the prompt in a Code node and reference the output with a single {{ $json.systemPrompt }} expression for cleaner configuration.

### What happens if the node I reference with $('NodeName') has not executed?

The expression throws an error or returns undefined. Wrap the reference in a try-catch in a Code node, or ensure the referenced node is always in the execution path by restructuring the workflow.

### How do I rename a node without breaking existing expressions?

n8n automatically updates expressions when you rename a node through the UI. However, Code node strings that reference $('OldName') are not updated automatically. Search your Code nodes for the old name and update manually.

### Can RapidDev help design n8n workflows with complex data flows?

Yes, RapidDev builds production n8n workflows with properly scoped variables, multi-source context aggregation, and clean data pipelines. Their team can restructure workflows to eliminate scoping issues and improve maintainability.

---

Source: https://www.rapidevelopers.com/n8n-tutorial/how-to-fix-variable-scoping-issues-with-user-context-in-n8n-prompts
© RapidDev — https://www.rapidevelopers.com/n8n-tutorial/how-to-fix-variable-scoping-issues-with-user-context-in-n8n-prompts
