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:
| Format | Extension | Best for |
|---|---|---|
| CSV | .csv | Spreadsheets and any tool that imports CSV |
| Excel | .xlsx | People who open the file in Excel, Numbers or Google Sheets |
| JSON Lines | .ndjson | Developers, scripts and log pipelines |
| SQL (PostgreSQL) | .sql | Loading into PostgreSQL, SQLite or DuckDB with one command |
| SQL (MySQL) | .sql | Loading into MySQL or MariaDB with one command |
| Parquet | .parquet | Data 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.
Compression
Section titled “Compression”- 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
.xlsxand.parquet.
- RFC 4180, UTF-8, a header row, lines ending in CRLF.
- Numbers and
true/falseas 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 —-3stays-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 onposition_tracker results (2)and so on. - Real cell types. Numbers are numbers,
true/falseare Excel booleans, and timestamps are real Excel dates shown asyyyy-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.
JSON Lines
Section titled “JSON Lines”One JSON object per line (also called NDJSON), UTF-8.
- Every exported column is present in every object —
nullwhen 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}SQL (PostgreSQL and MySQL)
Section titled “SQL (PostgreSQL and MySQL)”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_idis 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.
Loading a SQL file
Section titled “Loading a SQL file”Files from a bucket, Google Drive or an SFTP server are gzipped — decompress on the fly:
# PostgreSQLpsql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f position_tracker-results.sqlgunzip -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.sqlgunzip -c position_tracker-results_2026-09-30_<run_id>.sql.gz | mysql isearchfromPostgreSQL file
Section titled “PostgreSQL file”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.
MySQL file
Section titled “MySQL file”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_idisVARCHAR(191), the longestutf8mb4key MySQL can index. - List and JSON columns are
JSON; booleans areBOOLEAN. - 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_ESCAPESmode.
Changing columns
Section titled “Changing columns”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
Section titled “Parquet”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 type | Parquet type |
|---|---|
string | BYTE_ARRAY (UTF8) |
integer | INT64 |
float | DOUBLE |
boolean | BOOLEAN |
timestamp | INT64 TIMESTAMP(MICROS), adjusted to UTC |
json | BYTE_ARRAY (JSON) — JSON text |
string_array | LIST 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.
-- DuckDBSELECT keyword, position, measured_atFROM 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.
Duplicates
Section titled “Duplicates”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.