Push a file, get a link

rows.page takes CSV, TSV, JSON, JSONL and Parquet over plain HTTP. No SDK, and no account for the first push.

Push with curl

shell
curl -T data.csv https://rows.page

In a terminal you get plain text back:

response
https://rows.page/k3j9x2m1q7
owner link (keep, delete, re-push): https://rows.page/k3j9x2m1q7#t=rpt_...
48,210 rows x 12 columns, csv, 1.2 MB, expires 2026-10-06. Open the owner link and sign in to keep it.
re-push: curl -T data.csv -H "Authorization: Bearer rpt_..." https://rows.page/k3j9x2m1q7

The first line is the link to share. The owner link adds #t= plus the dataset token: whoever opens it can keep, rename, delete and pin findings. Browsers never send the part after # to a server, so the token stays out of logs.

JSON responses

Scripts and agents get JSON with Accept: application/json, ?json=1, or by POSTing the raw body to https://rows.page/api/v1/push.

shell
curl -T data.csv -H "Accept: application/json" https://rows.page
201 Created
{
  "id": "k3j9x2m1q7",
  "version": 1,
  "url": "https://rows.page/k3j9x2m1q7",
  "owner_url": "https://rows.page/k3j9x2m1q7#t=rpt_...",
  "token": "rpt_...",
  "format": "csv",
  "bytes": 1258291,
  "rows": 48210,
  "rows_exact": true,
  "columns": [
    { "name": "order_id", "type": "integer" },
    { "name": "country", "type": "string" },
    { "name": "amount", "type": "float" }
  ],
  "expires_at": "2026-10-06T09:12:44Z",
  "unchanged": false,
  "changes": null,
  "keep": {
    "required": true,
    "url": "https://rows.page/k3j9x2m1q7#t=rpt_...",
    "message": "Open the owner link and sign in to keep it."
  },
  "limits": {
    "plan": "anon",
    "max_file_bytes": 104857600,
    "datasets_used": null,
    "datasets_limit": null
  }
}

Store token. It's only returned when the dataset is created.

Keep a dataset

Anonymous datasets are deleted 7 days after creation. To keep one, open the owner link, sign in with Google and click Keep. Pushes made with an API key belong to your account from the start.

New versions

Push to the dataset's URL with its token and you get version 2, 3, and so on. Versions are immutable. The same bytes again add nothing and answer unchanged: true.

shell
curl -T data.csv -H "Authorization: Bearer rpt_..." https://rows.page/k3j9x2m1q7

With an API key you can push by your own key instead of tracking ids. The first push creates the dataset, later pushes add versions.

shell
curl --data-binary @orders.csv \
  -H "Authorization: Bearer rpk_..." \
  "https://rows.page/api/v1/push?key=nightly-orders&id_column=order_id&title=Nightly%20orders"

The page compares each version with the one before and counts the rows that are new, gone and changed. Set id_column so changed rows can be matched by id.

Parameters

Pass these in the query string or as X-Rows-* headers, like X-Rows-Title.

format
csv, tsv, json, jsonl or parquet. Otherwise taken from the file extension, then sniffed from the content.
name
File name for the version, for requests that don't carry one (like POST /api/v1/push).
title
Dataset title shown on the page.
summary
A few sentences shown above the findings. The page opens on it.
key
Your own stable name, like nightly-orders. Pushing the same key again adds a version to that dataset. Needs an API key.
id_column
Column that identifies a row, so the page can count changed rows, not only new and gone ones.
source
Where the data came from: a URL, a system, a query. Shown as provenance.
run
Job or run id, shown as provenance.
commit
Git commit that produced the data, shown as provenance.
visibility
public (default) or private. Private needs Pro or Max.
shell
curl -T results.jsonl \
  -H "X-Rows-Title: Eval run 412" \
  -H "X-Rows-Run: 412" \
  -H "X-Rows-Commit: $(git rev-parse HEAD)" \
  https://rows.page

Findings

A finding is a saved view with a title and a note: a filter q, a sort like -amount,name, the columns to show, or a SQL query. A dataset with findings opens on them, so pin what your reader should see first.

shell
curl -X POST https://rows.page/api/v1/datasets/k3j9x2m1q7/findings \
  -H "Authorization: Bearer rpt_..." \
  -H "content-type: application/json" \
  -d '{"title":"Refunds over $500 in NL","note":"All from one merchant.","q":"country:NL amount:>500","sort":"-amount"}'

Query language

The filter bar on every dataset and the q of a finding use the same syntax.

refund "late fee"
Case-insensitive match in any text column.
country:NL city:"New York"
Field matches a value.
-status:ok
Exclude matches.
email:* -email:*
Has a value, or is empty.
sku:AB*
Starts with.
amount:>500 amount:<=20
Compare with >, >=, < or <=.
amount:10..20
Range.
at:>2026-09-01
Dates and times as ISO 8601.
payload.customer.country:NL
Nested fields by dot path.
tags:urgent
List contains a value.
(country:NL OR country:BE) amount:>500
Terms all have to match. Use OR and parentheses for alternatives.
SELECT ... FROM data
A query starting with SELECT, WITH or FROM runs as DuckDB SQL on the table data.

Read data back

GET /api/v1/datasets/<id> returns meta, versions, findings and preview rows. /<id>/raw.csv serves the latest version's file and /<id>/v2/raw.csv a fixed one, so DuckDB, pandas, polars or curl read it straight from the URL. The extension is the pushed format: csv, tsv, json, jsonl or parquet.

shell
curl https://rows.page/api/v1/datasets/k3j9x2m1q7

duckdb -c "SELECT count(*) FROM 'https://rows.page/k3j9x2m1q7/raw.csv'"

Limits

Anonymous
100 MB per file, deleted 7 days after creation unless kept, up to 20 versions, 50 pushes and 1 GB per day per IP.
Free
10 datasets, 100 MB per file, last 3 versions.
Pro
100 datasets, 500 MB per file, 90 days of versions, private datasets.
Max
1,000 datasets, 2 GB per file, 1 year of versions, private datasets.
Rate limits
Per minute: anonymous pushes 10, pushes with a key 120, MCP calls 60, remote proxy 20.
Big files
One request carries up to 100 MB. Pro and Max upload bigger files in 64 MB parts.
In the browser
About 1.5 GB of data per tab. Past that you get a clear message instead of a crashed tab.

Plans and prices are on the pricing page.