Skip to content

File formats

CSV, Excel, JSON Lines, SQL for PostgreSQL and MySQL, and Parquet — what each export format contains, how values are written, and how to load SQL and Parquet files.

Every file-based export — a download, an email, a Google Drive folder, a cloud storage bucket or an SFTP server — writes one file per dataset per run, in the format you choose:

FormatExtensionBest for
CSV.csvSpreadsheets and any tool that imports CSV
Excel.xlsxPeople who open the file in Excel, Numbers or Google Sheets
JSON Lines.ndjsonDevelopers, scripts and log pipelines
SQL (PostgreSQL).sqlLoading into PostgreSQL, SQLite or DuckDB with one command
SQL (MySQL).sqlLoading into MySQL or MariaDB with one command
Parquet.parquetData lakes and warehouses — typed, compressed and fast to query

A cloud storage bucket takes every format except Excel. Parquet is included on the Agency plan; the others on Team and Agency — see plans and limits.

Every file has the columns of its dataset in reference order — or, if you picked columns, only those, plus row_id and measured_at, which are always included.

  • CSV, JSON Lines and SQL are text. In buckets, on Google Drive and on SFTP servers they are gzipped and end in .gz — ….csv.gz, ….ndjson.gz, ….sql.gz. Downloads (including email links) arrive as the plain file; your browser decompresses them.
  • Excel and Parquet are compressed by design, so they are never gzipped again: always .xlsx and .parquet.
  • RFC 4180, UTF-8, a header row, lines ending in CRLF.
  • Numbers and true / false as themselves; timestamps as ISO-8601 in UTC, e.g. 2026-09-30T06:02:14.512Z.
  • List columns (string_array) are joined with |; JSON columns are written as JSON text.
  • An empty value is an empty cell.
  • Formula protection. A text value starting with =, +, -, @, a tab or a carriage return is prefixed with ', so a spreadsheet never runs it as a formula. Numbers are never changed — -3 stays -3. If you load CSV into a database and need text byte-for-byte, use JSON Lines, SQL or Parquet instead.

An .xlsx workbook per dataset, with the header row frozen.

  • One sheet per dataset, named after it with . replaced by a space — position_tracker results. Excel allows 1,048,576 rows per sheet; a longer file continues on position_tracker results (2) and so on.
  • Real cell types. Numbers are numbers, true / false are Excel booleans, and timestamps are real Excel dates shown as yyyy-mm-dd hh:mm:ss — in UTC, since Excel dates carry no time zone.
  • Text always stays text. A value that looks like a formula is stored as a text cell, not run and not prefixed.
  • Long values are cut at Excel’s limit of 32,767 characters per cell. Long AI answers can reach it; use JSON Lines or Parquet when you need the full text.
  • Very large integers — above 2^53 (9,007,199,254,740,992), which Excel would round — are written as text so no digit is lost.
  • List columns are joined with |; JSON columns are JSON text.

One JSON object per line (also called NDJSON), UTF-8.

  • Every exported column is present in every object — null when empty.
  • Numbers and booleans are JSON numbers and booleans; timestamps are ISO-8601 strings in UTC; list columns are arrays of strings; JSON columns are objects.
{
"row_id": "…",
"measured_at": "2026-09-30T06:02:14.512Z",
"keyword": "coffee beans",
"position": 3
}

A SQL file is a script that loads one dataset into a table of your own database. Run it and the table is created if it doesn’t exist, and the rows are inserted.

-- position_tracker.results, exported by iSearchFrom. Columns: 18.
BEGIN;
CREATE TABLE IF NOT EXISTS "position_tracker_results" (
"row_id" TEXT PRIMARY KEY,
"measured_at" TIMESTAMPTZ NOT NULL,
...
);
INSERT INTO "position_tracker_results" ("row_id", "measured_at", ...) VALUES
(...),
(...) ON CONFLICT ("row_id") DO NOTHING;
COMMIT;
  • The table is named after the dataset with . replaced by _: position_tracker_results, ai_visibility_mentions, alerts.
  • row_id is the primary key. Every insert skips rows the table already has, so you can load overlapping files — a backfill, a retried run, the same file twice — without creating duplicates.
  • One transaction. The whole file loads, or none of it does.
  • Rows are inserted 500 per INSERT, small enough for any client’s packet limit.
  • List columns and JSON columns are stored as JSON.

Files from a bucket, Google Drive or an SFTP server are gzipped — decompress on the fly:

Terminal window
# PostgreSQL
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f position_tracker-results.sql
gunzip -c position_tracker-results_2026-09-30_<run_id>.sql.gz | psql "$DATABASE_URL" -v ON_ERROR_STOP=1
# SQLite 3.24 or later (PostgreSQL file)
sqlite3 isearchfrom.db < position_tracker-results.sql
# DuckDB (PostgreSQL file)
duckdb isearchfrom.duckdb < position_tracker-results.sql
# MySQL or MariaDB (MySQL file)
mysql isearchfrom < position_tracker-results.sql
gunzip -c position_tracker-results_2026-09-30_<run_id>.sql.gz | mysql isearchfrom

Plain types — TEXT, BIGINT, DOUBLE PRECISION, BOOLEAN, TIMESTAMPTZ, JSON — and ON CONFLICT (row_id) DO NOTHING, which PostgreSQL, SQLite 3.24+ and DuckDB all read, so one file serves all three.

Text uses standard SQL quoting, which needs standard_conforming_strings to be on — the PostgreSQL default since 9.1. SQLite and DuckDB always behave this way.

For MySQL and MariaDB: utf8mb4 throughout, an InnoDB table, ON DUPLICATE KEY UPDATE to skip existing rows.

  • Timestamps are DATETIME(6) holding UTC — no 2038 limit, and no conversion by the session’s time zone.
  • Text is LONGTEXT; row_id is VARCHAR(191), the longest utf8mb4 key MySQL can index.
  • List and JSON columns are JSON; booleans are BOOLEAN.
  • A value containing a backslash is written as a hex literal, so the file loads the same with or without the server’s NO_BACKSLASH_ESCAPES mode.

CREATE TABLE IF NOT EXISTS never changes a table that already exists. If an earlier file created it with different columns — because you changed the column pick or the dataset gained a column — adjust the table before loading the new file:

-- A column the new file has and the table doesn't:
ALTER TABLE "position_tracker_results" ADD COLUMN "description" TEXT;
-- A required column the new file no longer carries (PostgreSQL):
ALTER TABLE "position_tracker_results" ALTER COLUMN "campaign_name" DROP NOT NULL;

Or load into a new table and keep the same pick from then on.

Parquet is a columnar file with types built in — the natural format for a data lake. Files are Snappy-compressed, with one row group per 2,000 rows, and read without configuration by DuckDB, pandas, pyarrow, Polars, BigQuery, Snowflake, Redshift, Databricks and Spark.

Dataset typeParquet type
stringBYTE_ARRAY (UTF8)
integerINT64
floatDOUBLE
booleanBOOLEAN
timestampINT64 TIMESTAMP(MICROS), adjusted to UTC
jsonBYTE_ARRAY (JSON) — JSON text
string_arrayLIST of UTF8

Columns that are never empty in the dataset reference are REQUIRED; every other column is OPTIONAL. A reader that doesn’t understand the JSON annotation sees those columns as text.

-- DuckDB
SELECT keyword, position, measured_at
FROM read_parquet('position_tracker-results_*.parquet')
ORDER BY measured_at DESC;
# pandas (with pyarrow installed)
import pandas as pd
df = pd.read_parquet("position_tracker-results_2026-09-30_<run_id>.parquet")

For Snowflake, Redshift, Databricks and more, see warehouse recipes.

Delivery is at least once: a backfill, or a failed run followed by a successful one, can deliver a row again. It always has the same row_id. SQL files skip such rows by themselves; for every other format, de-duplicate on row_id when you load.