{"slug": "langchain-csv-sqlite-analytics-safer-ai-foundation", "title": "LangChain CSV SQLite Analytics: Safer AI Foundation", "summary": "A developer from Gate of AI published a tutorial on building a deterministic CSV-to-SQLite analytics foundation with guarded, read-only SQL, designed as a safe boundary for LangChain-style agents. The project uses only Python's standard library and emphasizes keeping database access, query limits, and authorization under application control rather than the model.", "body_md": "🚀 Technical Briefing:This tutorial is part of our deep-dive series on Agentic Workflows at[Gate of AI]. For the full technical breakdown, interactive code sandbox, and the native Arabic translation, visit the[original article here].\n\nBuild a deterministic CSV-to-SQLite analytics foundation with guarded, read-only SQL. It is designed as a safe boundary that a LangChain-style agent can call after its framework and model integration have been verified against current official documentation.\n\nThe supplied research context identifies the general pattern of using LangChain agents with external tools and the broader use case of asking questions about CSV data. It does not provide trusted, current documentation for a particular LangChain release, OpenAI model, package API, tracing product, or web framework. For that reason, this tutorial deliberately does not present unverified agent-framework code as production-ready.\n\nInstead, you will build the deterministic portion that should remain under application control regardless of which model or orchestration framework you select later. The project creates a CSV file, imports it into a local SQLite database, describes the approved schema, validates one read-only SQL statement at a time, opens the database in read-only mode for analytics queries, caps returned rows, and tests the important non-model behavior.\n\nThis separation matters. A language model may help choose a tool and formulate a question, but it should not receive a writable database connection, a shell function, unrestricted Python execution, or secrets. Your application should retain control of CSV ingestion, database access, query limits, authorization, logging policy, and the definition of approved business metrics.\n\nThis example uses Python 3.10 or later and only the Python standard library for the runnable application. SQLite is accessed through Python’s built-in `sqlite3`\n\nmodule. Install pytest separately if you want to run the tests.\n\n```\nmkdir csv-sqlite-analytics\ncd csv-sqlite-analytics\n\npython -m venv .venv\n\n# macOS and Linux\nsource .venv/bin/activate\n\n# Windows PowerShell\n# .\\.venv\\Scripts\\Activate.ps1\n\npython -m pip install --upgrade pip\npython -m pip install pytest\n\nmkdir data tests\n```\n\nCreate four files: `sample_data.py`\n\n, `database.py`\n\n, `app.py`\n\n, and `tests/test_database.py`\n\n. The command-line program accepts guarded SQL in this version. A future agent adapter can translate natural-language questions into SQL, but it must call the same validation and execution boundary shown here.\n\nA deterministic sample makes the behavior easy to inspect and test. The sample has order identifiers, regions, statuses, categories, quantities, prices, and totals. It is demonstration data only; replace it with a reviewed export only after removing fields that your users and application should not access.\n\n``` python\nfrom __future__ import annotations\n\nimport csv\nfrom pathlib import Path\n\nORDERS = [\n    [\"ORD-1001\", \"2026-01-05\", \"North\", \"Enterprise\", \"Analytics\", \"completed\", 3, 1200.00],\n    [\"ORD-1002\", \"2026-01-06\", \"South\", \"SMB\", \"Support\", \"completed\", 8, 150.00],\n    [\"ORD-1003\", \"2026-01-07\", \"West\", \"Enterprise\", \"Security\", \"completed\", 2, 2500.00],\n    [\"ORD-1004\", \"2026-01-08\", \"East\", \"Mid-Market\", \"Analytics\", \"pending\", 4, 900.00],\n    [\"ORD-1005\", \"2026-01-09\", \"North\", \"SMB\", \"Support\", \"completed\", 12, 125.00],\n    [\"ORD-1006\", \"2026-01-11\", \"West\", \"Enterprise\", \"Analytics\", \"completed\", 5, 1450.00],\n    [\"ORD-1007\", \"2026-01-13\", \"South\", \"Mid-Market\", \"Security\", \"cancelled\", 1, 2200.00],\n    [\"ORD-1008\", \"2026-01-15\", \"East\", \"SMB\", \"Support\", \"completed\", 6, 175.00],\n    [\"ORD-1009\", \"2026-01-18\", \"North\", \"Mid-Market\", \"Analytics\", \"completed\", 7, 980.00],\n    [\"ORD-1010\", \"2026-01-21\", \"West\", \"SMB\", \"Security\", \"completed\", 2, 2400.00],\n    [\"ORD-1011\", \"2026-01-25\", \"East\", \"Enterprise\", \"Analytics\", \"completed\", 4, 1600.00],\n    [\"ORD-1012\", \"2026-01-28\", \"South\", \"Mid-Market\", \"Support\", \"pending\", 10, 140.00],\n]\n\ndef create_sample_csv(destination: Path) -> None:\n    destination.parent.mkdir(parents=True, exist_ok=True)\n    with destination.open(\"w\", newline=\"\", encoding=\"utf-8\") as file:\n        writer = csv.writer(file)\n        writer.writerow([\n            \"order_id\", \"order_date\", \"region\", \"customer_segment\",\n            \"product_category\", \"status\", \"quantity\", \"unit_price\", \"order_total\",\n        ])\n        for order_id, order_date, region, segment, category, status, quantity, unit_price in ORDERS:\n            writer.writerow([\n                order_id, order_date, region, segment, category, status,\n                quantity, f\"{unit_price:.2f}\", f\"{quantity * unit_price:.2f}\",\n            ])\n\nif __name__ == \"__main__\":\n    create_sample_csv(Path(\"data/orders.csv\"))\n    print(\"Created data/orders.csv with 12 records.\")\n```\n\nRun `python sample_data.py`\n\n. The standard CSV writer is preferable to hand-built comma-separated strings because it correctly escapes values containing commas, quotes, or line breaks.\n\nThe importer below normalizes CSV headers into safe database identifiers, creates an `orders`\n\ntable, and uses parameterized inserts for values. Imported fields are stored as text. This conservative representation avoids unwanted coercion of values such as identifiers with leading zeroes. Numeric analysis explicitly casts appropriate fields to `REAL`\n\n.\n\n``` python\nfrom __future__ import annotations\n\nimport csv\nimport re\nimport sqlite3\nfrom pathlib import Path\nfrom typing import Any\n\nTABLE_NAME = \"orders\"\nIDENTIFIER = re.compile(r\"^[A-Za-z_][A-Za-z0-9_]*$\")\n\ndef normalize_identifier(value: str, used: set[str]) -> str:\n    name = re.sub(r\"[^A-Za-z0-9_]\", \"_\", value.strip().lower())\n    name = re.sub(r\"_+\", \"_\", name).strip(\"_\") or \"column\"\n    if name[0].isdigit():\n        name = f\"column_{name}\"\n    candidate = name\n    suffix = 2\n    while candidate in used:\n        candidate = f\"{name}_{suffix}\"\n        suffix += 1\n    used.add(candidate)\n    return candidate\n\ndef quote_identifier(identifier: str) -> str:\n    if not IDENTIFIER.fullmatch(identifier):\n        raise ValueError(f\"Unsafe identifier: {identifier!r}\")\n    return f'\"{identifier}\"'\n\ndef load_csv_into_sqlite(csv_path: Path, sqlite_path: Path) -> list[str]:\n    if not csv_path.exists():\n        raise FileNotFoundError(f\"CSV file does not exist: {csv_path}\")\n\n    with csv_path.open(\"r\", newline=\"\", encoding=\"utf-8-sig\") as file:\n        reader = csv.DictReader(file)\n        if not reader.fieldnames:\n            raise ValueError(\"CSV must have a header row.\")\n        source_headers = list(reader.fieldnames)\n        used: set[str] = set()\n        columns = [normalize_identifier(header, used) for header in source_headers]\n        rows = list(reader)\n\n    if not rows:\n        raise ValueError(\"CSV must contain at least one data row.\")\n\n    sqlite_path.parent.mkdir(parents=True, exist_ok=True)\n    with sqlite3.connect(sqlite_path) as connection:\n        table = quote_identifier(TABLE_NAME)\n        connection.execute(f\"DROP TABLE IF EXISTS {table}\")\n        definitions = \", \".join(f\"{quote_identifier(column)} TEXT\" for column in columns)\n        connection.execute(f\"CREATE TABLE {table} ({definitions})\")\n        insert_columns = \", \".join(quote_identifier(column) for column in columns)\n        placeholders = \", \".join(\"?\" for _ in columns)\n        statement = f\"INSERT INTO {table} ({insert_columns}) VALUES ({placeholders})\"\n        values = [tuple(row.get(header, \"\").strip() for header in source_headers) for row in rows]\n        connection.executemany(statement, values)\n\n    return columns\n\ndef get_schema(sqlite_path: Path) -> dict[str, Any]:\n    with sqlite3.connect(sqlite_path) as connection:\n        connection.row_factory = sqlite3.Row\n        columns = connection.execute(\"PRAGMA table_info(orders)\").fetchall()\n        count = connection.execute(\"SELECT COUNT(*) AS total FROM orders\").fetchone()[\"total\"]\n    return {\n        \"table_name\": TABLE_NAME,\n        \"row_count\": count,\n        \"columns\": [{\"name\": row[\"name\"], \"type\": row[\"type\"]} for row in columns],\n    }\n```\n\nThe identifier check is important because SQL parameters protect values, not SQL identifiers such as column names. Headers are normalized before being used to build SQL. Values, meanwhile, are sent through parameterized inserts rather than string interpolation.\n\nThe following program is the application boundary an agent should call. It rejects comments, semicolons, recursive queries, non-read-only starting keywords, and listed administrative or write operations. It also opens the database through a SQLite read-only URI and fetches no more than 100 visible rows. The URI is a second protective layer: even if validation is changed incorrectly, the query connection is not intended for writes.\n\n``` python\nfrom __future__ import annotations\n\nimport json\nimport re\nimport sqlite3\nfrom pathlib import Path\nfrom urllib.parse import quote\n\nfrom database import get_schema, load_csv_into_sqlite\n\nMAX_ROWS = 100\nFORBIDDEN = re.compile(\n    r\"\\b(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|REPLACE|VACUUM|ATTACH|DETACH|\"\n    r\"PRAGMA|REINDEX|ANALYZE|BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE)\\b\",\n    re.IGNORECASE,\n)\n\ndef validate_read_only_sql(sql: str) -> str:\n    candidate = sql.strip()\n    if not candidate:\n        raise ValueError(\"Query cannot be empty.\")\n    if len(candidate) > 4000:\n        raise ValueError(\"Query exceeds 4000 characters.\")\n    if \";\" in candidate or \"--\" in candidate or \"/*\" in candidate or \"*/\" in candidate:\n        raise ValueError(\"Comments and multiple statements are not allowed.\")\n    normalized = re.sub(r\"\\s+\", \" \", candidate).upper()\n    if not (normalized.startswith(\"SELECT \") or normalized.startswith(\"WITH \")):\n        raise ValueError(\"Only SELECT or WITH queries are allowed.\")\n    if \"WITH RECURSIVE\" in normalized or FORBIDDEN.search(candidate):\n        raise ValueError(\"Query contains a disallowed SQL operation.\")\n    return candidate\n\ndef run_query(sqlite_path: Path, sql: str) -> dict[str, object]:\n    safe_sql = validate_read_only_sql(sql)\n    uri = f\"file:{quote(str(sqlite_path.resolve()))}?mode=ro\"\n    with sqlite3.connect(uri, uri=True) as connection:\n        connection.row_factory = sqlite3.Row\n        cursor = connection.execute(safe_sql)\n        rows = cursor.fetchmany(MAX_ROWS + 1)\n    return {\n        \"row_count_returned\": min(len(rows), MAX_ROWS),\n        \"truncated\": len(rows) > MAX_ROWS,\n        \"rows\": [dict(row) for row in rows[:MAX_ROWS]],\n    }\n\ndef main() -> None:\n    csv_path = Path(\"data/orders.csv\")\n    sqlite_path = Path(\"data/orders.sqlite3\")\n    load_csv_into_sqlite(csv_path, sqlite_path)\n    print(json.dumps(get_schema(sqlite_path), indent=2))\n    print(\"Enter read-only SQL, /schema, or /quit.\")\n\n    while True:\n        try:\n            request = input(\"SQL> \").strip()\n        except (EOFError, KeyboardInterrupt):\n            print(\"\\nGoodbye.\")\n            return\n        if request.lower() in {\"/quit\", \"/exit\"}:\n            print(\"Goodbye.\")\n            return\n        if request.lower() == \"/schema\":\n            print(json.dumps(get_schema(sqlite_path), indent=2))\n            continue\n        try:\n            print(json.dumps(run_query(sqlite_path, request), indent=2))\n        except (ValueError, sqlite3.Error) as error:\n            print(f\"Rejected or invalid query: {error}\")\n\nif __name__ == \"__main__\":\n    main()\n```\n\nSave this file as `app.py`\n\nand run `python app.py`\n\n. Then enter the following query:\n\n```\nSELECT product_category,\n       ROUND(SUM(CAST(order_total AS REAL)), 2) AS completed_revenue\nFROM orders\nWHERE status = 'completed'\nGROUP BY product_category\nORDER BY completed_revenue DESC\nLIMIT 1\n```\n\nThe explicit cast prevents text ordering and aggregation from being confused with numeric analysis. The result is also scoped to completed records, which is one possible definition of realized revenue in this sample. A real organization must document its own metric definitions; a query cannot resolve ambiguity about booked, invoiced, collected, gross, net, refunded, or recognized revenue.\n\nTests should exercise the ingestion and query guardrails without a model call. This makes failures fast to reproduce and keeps safety behavior independent of prompt wording or model output.\n\n``` python\nfrom pathlib import Path\n\nimport pytest\n\nfrom app import run_query, validate_read_only_sql\nfrom database import get_schema, load_csv_into_sqlite\nfrom sample_data import create_sample_csv\n\ndef test_load_and_schema(tmp_path: Path) -> None:\n    csv_path = tmp_path / \"orders.csv\"\n    sqlite_path = tmp_path / \"orders.sqlite3\"\n    create_sample_csv(csv_path)\n    load_csv_into_sqlite(csv_path, sqlite_path)\n    schema = get_schema(sqlite_path)\n    assert schema[\"table_name\"] == \"orders\"\n    assert schema[\"row_count\"] == 12\n    assert any(column[\"name\"] == \"order_total\" for column in schema[\"columns\"])\n\ndef test_aggregate_query(tmp_path: Path) -> None:\n    csv_path = tmp_path / \"orders.csv\"\n    sqlite_path = tmp_path / \"orders.sqlite3\"\n    create_sample_csv(csv_path)\n    load_csv_into_sqlite(csv_path, sqlite_path)\n    result = run_query(sqlite_path, \"SELECT region, COUNT(*) AS n FROM orders GROUP BY region\")\n    assert result[\"truncated\"] is False\n    assert result[\"row_count_returned\"] == 4\n\n@pytest.mark.parametrize(\"sql\", [\n    \"DELETE FROM orders\",\n    \"DROP TABLE orders\",\n    \"SELECT * FROM orders; DELETE FROM orders\",\n    \"SELECT * FROM orders -- comment\",\n    \"WITH RECURSIVE n(x) AS (SELECT 1) SELECT x FROM n\",\n])\ndef test_disallowed_sql(sql: str) -> None:\n    with pytest.raises(ValueError):\n        validate_read_only_sql(sql)\n```\n\nRun `pytest -q`\n\n. If a disallowed statement begins to pass, stop and review the change before adding further features. A permissive boundary is not a presentation issue; it changes what the application can do with a model-generated request.\n\nWhen you have current official documentation for the exact LangChain release you plan to deploy, expose two narrow functions as tools: one that returns `get_schema()`\n\nand one that accepts SQL and calls `run_query()`\n\n. The model-facing tool description should state that `orders`\n\nis the approved table, source columns are text, numeric calculations require explicit casts, and list-style requests should use a limit.\n\nDo not give the agent a raw SQLite connection, filesystem access, arbitrary Python execution, or a function that can modify the database. Do not place API keys in prompts, tool descriptions, CSV values, or logs. Maintain a bounded conversation history and require the agent to use the query tool for factual numerical answers rather than inventing figures.\n\nBefore using organizational data, review each column and remove data that is unnecessary for the analytics task. For any GCC or Middle East deployment, confirm the applicable organizational requirements for access, retention, residency, and handling of personal or confidential data with the relevant legal, security, and data-governance teams. A local SQLite demonstration does not establish production compliance.\n\nThe next technical step is not to add more autonomy; it is to add control. Create an approved data dictionary, document metric definitions, allowlist tables and columns, and record sanitized query metadata such as request ID, execution time, row count, truncation status, and error category. Do not record secrets or unrestricted raw sensitive values.\n\nFor a production analytics store, use a database identity that has access only to approved reporting views and apply authorization before a query reaches the database. Keep result-size limits, query budgets, and a regression suite containing valid aggregations, missing-column requests, empty results, ambiguous terms, and attempted prompt-injection text in dataset fields.\n\nThis foundation is intentionally modest: deterministic software prepares and protects data, while an agent framework—once independently verified and version-pinned—can supply the conversational layer. That division keeps the important access and safety decisions in code you can inspect and test.", "url": "https://wpnews.pro/news/langchain-csv-sqlite-analytics-safer-ai-foundation", "canonical_source": "https://dev.to/gateofai/langchain-csv-sqlite-analytics-safer-ai-foundation-1208", "published_at": "2026-08-30 16:58:22+00:00", "updated_at": "2026-08-30 17:23:28.830692+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents"], "entities": ["Gate of AI", "LangChain", "SQLite", "Python"], "alternates": {"html": "https://wpnews.pro/news/langchain-csv-sqlite-analytics-safer-ai-foundation", "markdown": "https://wpnews.pro/news/langchain-csv-sqlite-analytics-safer-ai-foundation.md", "text": "https://wpnews.pro/news/langchain-csv-sqlite-analytics-safer-ai-foundation.txt", "jsonld": "https://wpnews.pro/news/langchain-csv-sqlite-analytics-safer-ai-foundation.jsonld"}}