LLMs & Agents
LangGraph
LangChain
Python
AI agents
ReAct
LLMs

AI Agents with LangGraph for Data Analysis

Build an autonomous data analysis agent with LangGraph: implement the ReAct loop with tools for querying data, and learn when to use agents vs RAG.

June 18, 2026·7 min read

In a previous article I showed how to build a RAG system with LangChain: you index documents, retrieve the most relevant chunks, and feed them to an LLM. That works well when the answer already exists somewhere in your documents and you just need to find it.

But what if the question requires multiple steps? "Which customers churned last month and what did they have in common?" — this requires loading data, filtering, aggregating, and interpreting results. A RAG system cannot do that. An agent can — and in this post you'll build one with LangGraph: a data analysis agent that loads CSVs, runs pandas queries, and reports its findings with numbers.

Agents vs. RAG: The Key Difference

A RAG system is a single-pass pipeline: retrieve → generate.

An agent is a loop:

  1. Reason: given the goal and current state, what should I do next?
  2. Act: call a tool (run SQL, load a CSV, plot a chart)
  3. Observe: read the tool output
  4. Go back to step 1 until the goal is achieved

This is the ReAct (Reason + Act) pattern. LangGraph makes it easy to implement as an explicit state machine.

Setup

bash
pip install langgraph langchain langchain-openai pandas numpy

You will need an OpenAI API key (or swap the model for any LangChain-compatible LLM):

bash
export OPENAI_API_KEY="sk-..."

Defining the Agent State

LangGraph models the agent as a directed graph where each node transforms a shared state. We start by defining that state:

python
from typing import TypedDict, Annotated, Sequence
from langchain_core.messages import BaseMessage
import operator

class AgentState(TypedDict):
    messages: Annotated[Sequence[BaseMessage], operator.add]
    data_loaded: bool
    dataset_summary: str

messages accumulates the conversation history. operator.add tells LangGraph to append new messages rather than replace the list. The other fields give the agent working memory across turns.

Building the Tools

Tools are the actions the agent can take. We define three:

python
import pandas as pd
import numpy as np
from langchain_core.tools import tool

# In-memory "database" for the demo
_df: pd.DataFrame | None = None

@tool
def load_dataset(path: str) -> str:
    """Load a CSV dataset into memory. Returns a summary of columns and shape."""
    global _df
    _df = pd.read_csv(path)
    summary = (
        f"Dataset loaded: {_df.shape[0]} rows × {_df.shape[1]} columns.\n"
        f"Columns: {', '.join(_df.columns)}\n"
        f"Dtypes:\n{_df.dtypes.to_string()}"
    )
    return summary

@tool
def run_query(query: str) -> str:
    """
    Run a pandas query on the loaded dataset.
    Use standard pandas expressions, e.g.:
      - df[df['churn'] == 1]['monthly_charges'].mean()
      - df.groupby('plan')['churn'].value_counts()
    The variable 'df' refers to the loaded dataset.
    """
    global _df
    if _df is None:
        return "No dataset loaded. Call load_dataset first."
    try:
        result = eval(query, {'df': _df, 'pd': pd, 'np': np})
        return str(result)
    except Exception as e:
        return f"Query error: {e}"

@tool
def describe_column(column_name: str) -> str:
    """Return descriptive statistics for a specific column in the loaded dataset."""
    global _df
    if _df is None:
        return "No dataset loaded."
    if column_name not in _df.columns:
        return f"Column '{column_name}' not found. Available: {list(_df.columns)}"
    stats = _df[column_name].describe().to_string()
    null_pct = _df[column_name].isnull().mean() * 100
    return f"{stats}\nNull values: {null_pct:.1f}%"

tools = [load_dataset, run_query, describe_column]

A warning about run_query: passing LLM-generated code to eval is fine for a local demo, but never do it in production — a prompt-injected query could execute arbitrary code. Sandbox the execution (restricted process, container) or use a safe expression evaluator instead.

Building the Graph

Now we wire the agent together using LangGraph:

python
from langchain_openai import ChatOpenAI
from langchain_core.messages import SystemMessage
from langgraph.graph import StateGraph, END
from langgraph.prebuilt import ToolNode

# LLM with tools bound
llm = ChatOpenAI(model='gpt-4o-mini', temperature=0)
llm_with_tools = llm.bind_tools(tools)

SYSTEM_PROMPT = """You are a data analysis assistant. You have access to tools that 
let you load CSV datasets, run pandas queries, and describe columns.

When a user asks a question:
1. Load the dataset if it has not been loaded yet.
2. Explore the data to understand its structure.
3. Run the specific queries needed to answer the question.
4. Summarize your findings clearly, with numbers.

Always verify your queries worked before drawing conclusions."""

def agent_node(state: AgentState) -> AgentState:
    """The reasoning node: calls the LLM to decide the next action."""
    messages = [SystemMessage(content=SYSTEM_PROMPT)] + list(state['messages'])
    response = llm_with_tools.invoke(messages)
    return {'messages': [response]}

def should_continue(state: AgentState) -> str:
    """Routing function: continue the loop or end?"""
    last_message = state['messages'][-1]
    if hasattr(last_message, 'tool_calls') and last_message.tool_calls:
        return 'tools'
    return END

# Build the graph
tool_node = ToolNode(tools)

graph = StateGraph(AgentState)
graph.add_node('agent', agent_node)
graph.add_node('tools', tool_node)

graph.set_entry_point('agent')
graph.add_conditional_edges('agent', should_continue)
graph.add_edge('tools', 'agent')  # After tool call, go back to agent

agent = graph.compile()

The graph has two nodes — agent and tools — connected in a loop. After every tool call, control returns to the agent node, which reasons about the result and decides whether to call another tool or stop.

Running the Agent

python
from langchain_core.messages import HumanMessage

def run_agent(question: str, data_path: str | None = None):
    initial_message = HumanMessage(
        content=f"Dataset path: {data_path}\n\nQuestion: {question}"
        if data_path else question
    )

    result = agent.invoke(
        {'messages': [initial_message], 'data_loaded': False, 'dataset_summary': ''},
        config={'recursion_limit': 15},
    )

    final_message = result['messages'][-1]
    return final_message.content

# Example: analyze a sample dataset
answer = run_agent(
    question="What is the churn rate by number of products? Which group has the highest churn?",
    data_path="data/churn.csv",
)
print(answer)

A sample output (the agent's final answer after running multiple tool calls):

I loaded the dataset (2000 rows × 6 columns) and ran the analysis.

Churn rate by number of products:
  1 product:  42.1%  ← highest churn
  2 products: 31.7%
  3 products: 22.4%
  4 products: 15.8%
  5 products: 9.3%   ← lowest churn

Customers with only 1 product have the highest churn rate at 42.1%, 
which is 4.5× higher than customers with 5 products. This suggests 
that cross-selling additional products significantly improves retention.

Inspecting the Agent's Reasoning

To debug what the agent did at each step:

python
from langchain_core.messages import AIMessage, ToolMessage

result = agent.invoke({
    'messages': [HumanMessage(content="Load data/churn.csv and tell me the average monthly charges for churned vs non-churned customers.")],
    'data_loaded': False,
    'dataset_summary': '',
})

for msg in result['messages']:
    if isinstance(msg, AIMessage) and msg.tool_calls:
        for tc in msg.tool_calls:
            print(f"→ Tool call: {tc['name']}({tc['args']})")
    elif isinstance(msg, ToolMessage):
        print(f"← Tool result: {msg.content[:200]}")
    elif isinstance(msg, AIMessage):
        print(f"✓ Final answer: {msg.content}")
→ Tool call: load_dataset({'path': 'data/churn.csv'})
← Tool result: Dataset loaded: 2000 rows × 6 columns. Columns: tenure_months, monthly_charges, ...
→ Tool call: run_query({'query': "df.groupby('churn')['monthly_charges'].mean()"})
← Tool result: churn\n0    58.3\n1    79.2\ndtype: float64
✓ Final answer: Churned customers pay significantly more on average ($79.2/month) compared to 
  retained customers ($58.3/month)...

Agents vs. RAG: When to Use Each

ScenarioUse
"Summarize this 50-page contract"RAG
"Which clause limits liability?"RAG
"What was our Q3 churn rate?"Agent
"Compare churn across our three product tiers"Agent
"Find the customers most likely to churn next month"Agent

The rule of thumb: if the question requires computation or multi-step reasoning over structured data, use an agent. If it requires finding and paraphrasing information from documents, use RAG. And if what you need is to pull fixed fields out of documents — invoices, contracts, job postings — you don't need an agent at all: structured data extraction with LLMs solves it with a single validated call.

Conclusion

LangGraph gives you explicit control over the agent loop — state, transitions, and tool execution — without hiding complexity behind magic. The result is a system you can inspect, debug, and extend. From here, you can add more tools (database connectors, plotting libraries, API calls), persist state across sessions, or build multi-agent architectures where specialized agents collaborate.


About the author

Rodrigo Arenas is a software architect and machine learning engineer in Medellín, Colombia. He builds products, platforms, and open-source software for AI, Data Engineering, and Machine Learning — creator of Ciaren, sklearn-genetic-opt, and PyWorkforce.



Rodrigo Arenas — ML Engineer & Open Source Maintainer

Working on something similar? I can help you take it to production.

Building an AI or data platform?

Products, platforms, and open-source software for AI, Data Engineering, and Machine Learning.

View Projects
Vision

Building an ecosystem of products and open-source software for AI, Data Engineering, and Machine Learning.

Projects
sklearn-genetic-optPyWorkforceCiaren

Iterixs (Currently building)

© 2026 Rodrigo Arenas