---
title: "Ingest CSV into LlamaIndex with EnConvert"
description: "Use EnConvert to ingest a CSV into LlamaIndex as Markdown table chunks that stay whole, with section metadata so retrieval can filter by section in one job"
canonical: "https://www.enconvert.com/solutions/ingest-csv-into-llamaindex-with-enconvert"
locale: "en"
---

# How to Ingest CSV into LlamaIndex with EnConvert

A developer building a LlamaIndex agent or RAG pipeline that reads CSV files needs structured chunks where each table stays whole, not flat row text that loses the relationship between values and their column headers. Serialising CSV rows as plain text with pandas or the csv module either drops the column names or repeats them on every row, with no meaningful chunk boundaries. Nothing populates metadata a LlamaIndex retriever can filter on. For an agent reasoning over a product catalogue, a CRM export, or a financial dataset, bare row values without column context are not useful retrieval units. EnConvert’s Ingest endpoint takes the uploaded CSV, converts it to Markdown, and returns JSONL chunks where each table stays whole, with headings_path and section metadata on every chunk, so the content is ready to load as LlamaIndex Document objects and retrieve in one call.

## Example

```python
import os
import json
import time

import requests
from llama_index.core import VectorStoreIndex
from llama_index.core.schema import TextNode
from llama_index.core.vector_stores import (
    FilterOperator,
    MetadataFilter,
    MetadataFilters,
)
from llama_index.embeddings.openai import OpenAIEmbedding

HEADERS = {"X-API-Key": os.environ["ENCONVERT_API_KEY"]}
CSV_PATH = "/path/to/your/product-catalogue.csv"  # replace with your own CSV

# Step 1: Upload the CSV
# The multipart field is "files" (repeat it to send several files).
# EnConvert converts the CSV to a Markdown table and never splits a table across chunks.
with open(CSV_PATH, "rb") as f:
    upload = requests.post(
        "https://api.enconvert.com/v2/ingest/files",
        headers=HEADERS,
        files={"files": (os.path.basename(CSV_PATH), f, "text/csv")},
        timeout=60,
    )
upload.raise_for_status()
job_id = upload.json()["job_id"]

# Step 2: Poll until the job completes (give up after 5 minutes)
deadline = time.monotonic() + 300
while True:
    status_response = requests.get(
        f"https://api.enconvert.com/v2/ingest/{job_id}",
        headers=HEADERS,
        timeout=60,
    )
    status_response.raise_for_status()
    job = status_response.json()
    if job["status"] == "completed":
        output_url = job["output_url"]
        break
    if job["status"] in ("failed", "canceled"):
        raise RuntimeError(
            f"Ingest job {job_id} {job['status']}: {job.get('error_message')}"
        )
    if time.monotonic() > deadline:
        raise TimeoutError(f"Ingest job {job_id} is still {job['status']}")
    time.sleep(2)

# Step 3: Download the JSONL output
# The signed URL expires after 15 minutes. Plain GET, no API key.
jsonl_response = requests.get(output_url, timeout=60)
jsonl_response.raise_for_status()

# Split on "\n" only: str.splitlines() also breaks on characters that can appear raw in content.
chunks = [
    json.loads(line)
    for line in jsonl_response.content.decode("utf-8").split("\n")
    if line.strip()
]
if not chunks:
    raise RuntimeError(f"Ingest job {job_id} returned no chunks")
print(f"Loaded {len(chunks)} chunks ready for embedding")

# Step 4: Turn each chunk into a LlamaIndex TextNode with its metadata preserved
# headings_path is a list, so join it to keep the metadata flat for any vector store.
# A CSV is one table with no headings, so headings_path and section come back empty.
# They are filled for formats with headings, such as XLSX, DOCX and PPTX.
nodes = []
for chunk in chunks:
    metadata = {
        "source_url": chunk["metadata"]["source_url"],
        "title": chunk["metadata"]["title"],
        "section": chunk["metadata"]["section"],
        "headings_path": " > ".join(chunk["metadata"]["headings_path"]),
        "chunk_index": chunk["metadata"]["chunk_index"],
        "word_count": chunk["metadata"]["word_count"],
    }
    nodes.append(
        TextNode(
            id_=chunk["id"],
            text=chunk["content"],
            metadata=metadata,
            # Embed only the table text, not the metadata fields
            excluded_embed_metadata_keys=list(metadata),
        )
    )

# Step 5: Build the vector index
# Pass nodes= (not from_documents) so LlamaIndex does not re-split the whole tables.
# Each table is one node, so keep the CSV small enough for the embedding model's input limit.
index = VectorStoreIndex(
    nodes=nodes,
    embed_model=OpenAIEmbedding(
        model="text-embedding-3-small",
        api_key=os.environ["OPENAI_API_KEY"],
    ),
)

# Step 6: Retrieve, restricted to one file by a metadata filter
# source_url holds the filename as EnConvert labelled it (normalised, so it can
# differ from the raw name). Read it back from the data instead of retyping it.
# The filter keeps retrieval on this file even when several files share the index.
source_name = chunks[0]["metadata"]["source_url"]
filters = MetadataFilters(
    filters=[
        MetadataFilter(key="source_url", operator=FilterOperator.EQ, value=source_name)
    ]
)

retriever = index.as_retriever(similarity_top_k=4, filters=filters)

# Replace the question with one about your own data
results = retriever.retrieve("which products are suitable for outdoor installation")
for result in results:
    print(result.node.metadata["source_url"])
    print(result.node.get_content()[:200])
```

## Output

Ingest returns the CSV as Markdown table chunks where each table stays whole even when it exceeds the 512 word default soft cap, with headings_path and section metadata on every chunk, so LlamaIndex retrieval can filter by section. Serialising CSV rows as plain text with pandas or the csv module gives flat strings with column names either dropped or repeated on every row, with no chunk boundaries and no metadata a LlamaIndex retriever can filter on.

## Get started

[Get free API key — 500 ops per month, no card required.](https://www.enconvert.com/auth?mode=signup)

## Related solutions

- [How to Scrape Wikipedia with LlamaIndex](https://www.enconvert.com/solutions/scrape-wikipedia-with-llamaindex-enconvert.md)
- [How to Ingest Markdown into LangChain with EnConvert](https://www.enconvert.com/solutions/ingest-markdown-into-langchain-with-heading-context.md)
- [Best JigsawStack Alternative for Structured Data Extraction](https://www.enconvert.com/solutions/jigsawstack-alternative-for-structured-data-enconvert.md)
- [How to Ingest Audio Transcripts into Qdrant with EnConvert](https://www.enconvert.com/solutions/ingest-audio-transcripts-into-qdrant-enconvert-api.md)
- [How to Ingest a CSV into pgvector Without Losing Row Context](https://www.enconvert.com/solutions/ingest-csv-into-pgvector-with-row-context-enconvert-api.md)
- [How to Ingest CSV into CrewAI with EnConvert](https://www.enconvert.com/solutions/ingest-csv-into-crewai-with-enconvert.md)
