# Join Datasets: VLOOKUP & Merge Two Datasets or CSV Files (`humble-echidna/dataset-join`) Actor

Join two Apify datasets or CSV, JSON or Excel files by a key, like VLOOKUP or a SQL join: add the fields of the matching lookup row to each row. Match on text, exact values or website domains; left, inner or anti join. Enrich scraper results with other actors' data. Pay per row.

- **URL**: https://apify.com/humble-echidna/dataset-join.md
- **Developed by:** [Michael Costa](https://apify.com/humble-echidna) (community)
- **Categories:** Developer tools, Automation, Integrations
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.70 / 1,000 joined rows

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

### What does Join Datasets do?

**Join Datasets** joins **two Apify datasets or CSV, JSON or Excel files by a key**, like **VLOOKUP** or a **SQL
join**: each row of your main data gets the fields of its matching row in the lookup data. Match on text, exact
values or **website domains**; keep every row (left join), only matches (inner) or only non-matches (anti).

It's the step that puts two scrapers' results side by side: Google Maps leads plus each website's tech stack,
products plus their stock from another source, companies plus the contacts you already have in your CRM export.

[Input fields](https://apify.com/humble-echidna/dataset-join/input-schema) ·
[API](https://apify.com/humble-echidna/dataset-join/api) ·
[Use it from Claude, ChatGPT or Cursor (MCP)](#can-i-use-join-datasets-from-an-ai-agent-mcp)

**Jump to:** [Fields](#what-data-does-join-datasets-return) · [Price](#how-much-does-it-cost-to-join-two-datasets) ·
[How to use](#how-to-join-two-datasets-or-files) · [Input](#input) · [Output](#output) ·
[Chain it](#run-it-after-a-scraper-or-on-a-schedule) · [AI agents (MCP)](#can-i-use-join-datasets-from-an-ai-agent-mcp) ·
[Limits](#limits) · [FAQ](#faq)

**Try it in one click:** the input comes pre-filled with the US Treasury's official exchange rates on 2025-12-31,
joined by currency with the same table a year earlier: each of the 174 rates gets last year's rate next to it (173
matched, 1 currency was new). 174 rows, about $0.17 (174 × $0.001, plus $0.00005 for the run start). **Then pick your
own datasets or files.**

### What data does Join Datasets return?

Each main row with the lookup fields added. From the example:

| Field | Example | Notes |
|---|---|---|
| *(your main fields)* | `"country": "Afghanistan"`, `"exchange_rate": "65.96"` | The main row as it is. |
| `rateAYearEarlier` | `70.35` | A lookup field, renamed (`exchange_rate -> rateAYearEarlier`). |
| `rateDateAYearEarlier` | `2024-12-31` | Another lookup field, renamed. |
| `joinMatched` | `true` | Whether the row found a lookup row (turn off with **Add a matched true/false field**). |

Every row has the same columns: when there's no match, the added fields are `null`. A lookup field whose name the
main row already has is added as `lookup_<name>`, so your main data is never overwritten. The run's `RUN_STATS` record
counts the rows read, matched and not matched.

### How much does it cost to join two datasets?

You pay per row written: **$1.00 per 1,000 rows**, plus $0.00005 each time a run starts. Matched or not, it's the
same price, and the lookup rows you join with aren't charged.

**It's cheaper on paid Apify plans:** $0.90 per 1,000 rows on Starter, $0.80 on Scale and $0.70 on Business. The
prices on this page are the Free-plan price, so on a paid plan you pay less than the examples show.

- **The example:** 174 rows × $0.001 = about $0.17, plus the start fee.
- **For example:** 2,000 Google Maps leads joined with their websites' tech stacks: 2,000 × $0.001 = **$2.00**; only
  the 300 leads not in your CRM yet (anti join): **$0.30**.
- **Caps:** **Max rows per run** in the input, and **Maximum cost per run** in the run options. The run stops cleanly
  at whichever comes first.

**Never charged:** main rows an inner or anti join leaves out, lookup rows, and rows too large to write.

### How to join two datasets or files

1. Open Join Datasets and click **Try for free** (or **Start** if you're signed in).
2. Pick the rows to enrich in **Main data: Apify dataset** (or paste a link in **Or: main data file URL**).
3. Pick the rows with the extra fields in **Lookup data** (a dataset or a file link).
4. In **Join on**, name the field that links them: `url`, or `website = domain` when the two call it differently.
   For websites, set **Match values as** to **Website domain**.
5. Optional: choose **Which rows to keep** and the **Lookup fields to add**, then click **Start** and open the
   **Output** tab (export as JSON, CSV or Excel).

### Example: exchange rates now and a year earlier

The pre-filled input (two public CSV files, joined by currency, two lookup fields renamed):

```json
{"fileUrl": "https://api.fiscaldata.treasury.gov/services/api/fiscal_service/v1/accounting/od/rates_of_exchange?format=csv&filter=record_date:eq:2025-12-31&page[size]=300",
 "lookupFileUrl": "https://api.fiscaldata.treasury.gov/services/api/fiscal_service/v1/accounting/od/rates_of_exchange?format=csv&filter=record_date:eq:2024-12-31&page[size]=300",
 "joinOn": ["country_currency_desc"],
 "lookupFields": ["exchange_rate -> rateAYearEarlier", "record_date -> rateDateAYearEarlier"]}
```

Real output from a local run on 2026-10-03 (the Treasury's bookkeeping columns left out):

```json
[
  {"country": "Afghanistan", "currency": "Afghani", "exchange_rate": "65.96", "record_date": "2025-12-31",
   "rateAYearEarlier": "70.35", "rateDateAYearEarlier": "2024-12-31", "joinMatched": true},
  {"country": "Euro Zone", "currency": "Euro", "exchange_rate": "0.851", "record_date": "2025-12-31",
   "rateAYearEarlier": "0.961", "rateDateAYearEarlier": "2024-12-31", "joinMatched": true},
  {"country": "Curacao", "currency": "Caribbean Guilder", "exchange_rate": "1.78", "record_date": "2025-12-31",
   "rateAYearEarlier": null, "rateDateAYearEarlier": null, "joinMatched": false}
]
```

### Ready-to-run examples

Each example opens with the input already filled in. Run it as it is, or change the input first.

- **[VLOOKUP two CSV files: add a column from another file](https://apify.com/humble-echidna/dataset-join/examples/vlookup-two-csv-files)**: Each row of the main file gets the matching row's fields from the lookup file, renamed as you like.
- **[Find rows that are missing from another file (anti join)](https://apify.com/humble-echidna/dataset-join/examples/rows-missing-from-another-file)**: Keep only the rows of one table with no match in the other, e.g. leads not yet in your CRM.

### Input

| Field | What it does |
|---|---|
| **Main data: Apify dataset** | The rows to add fields to; every output row is one of these. Or a file in **Or: main data file URL**. |
| **Lookup data** (dataset or file URL) | The rows with the fields to add, like VLOOKUP's second table. |
| **Join on** | `field`, or `main field = lookup field`; several lines join on all of them (`name` and `city`). Dots reach nested fields. |
| Match values as | **Text** (ignores case and extra spaces; the default), **Exactly as written**, or **Website domain**. |
| Which rows to keep | **Every main row** (left join, the default), **only matches** (inner), **only non-matches** (anti). |
| When a key matches several lookup rows | **The first** (like VLOOKUP, the default) or **one row per match** (like SQL). |
| Lookup fields to add (and rename) | `field` or `field -> new name`; empty adds every lookup field. |
| Prefix for the added fields | E.g. `crm_` gives `crm_owner`. |
| Add a matched true/false field | Adds `joinMatched` (on by default). |
| Max rows per run | Cap the rows written. |

**Website domain** matching reads `https://www.acme.com/about`, `acme.com`, `ACME.com.` and `info@acme.com` all as
`acme.com`, so a Maps scraper's `website`, a crawler's page `url` and a CRM's email domain line up. **Text** matching
treats `12` and `"12"` as the same (a CSV and a dataset join fine), and ignores how an accent is encoded. A row with an
empty key never matches.

A full example, Google Maps leads joined with Tech Stack Detector's results:

```json
{
  "datasetId": "<Google Maps scraper's dataset>",
  "lookupDatasetId": "<Tech Stack Detector's dataset>",
  "joinOn": ["website = url"],
  "matchAs": "domain",
  "lookupFields": ["technologies -> techStack", "categories -> techCategories"]
}
```

### Output

One dataset row per main row (or per match, with **one row per match**): the main row's fields, then the added
lookup fields, then `joinMatched`. The dataset can be downloaded as CSV, JSON, Excel, XML or HTML at any time, or
passed on to the next actor.

### Run it after a scraper, or on a schedule

To join each new scrape with a lookup dataset you keep (a CRM export, a list of target domains), chain it:

1. Open the scraper or its saved task, go to the **Integrations** tab and add **Apify actor** → **Join Datasets**,
   triggered when a run **succeeds**.
2. Set `"datasetId": "{{resource.defaultDatasetId}}"` (Apify fills in the finished run's dataset), your
   `lookupDatasetId` (a named dataset keeps its name between runs) or `lookupFileUrl`, and `joinOn`.

Or save the input as a [task](https://docs.apify.com/platform/actors/running/tasks) and
[schedule](https://docs.apify.com/platform/schedules) it, and collect the rows from the
[API](https://apify.com/humble-echidna/dataset-join/api), a
[webhook](https://docs.apify.com/platform/integrations/webhooks), or Make, Zapier and n8n through
[Apify's integrations](https://docs.apify.com/platform/integrations).

#### Can I use Join Datasets from an AI agent (MCP)?

Yes, through [Apify's MCP server](https://docs.apify.com/platform/integrations/mcp), from Claude, ChatGPT, Cursor or
any other MCP client. Add this to your client's MCP configuration (or let the agent find it with the server's actor
search); your client signs you in to Apify:

```json
{
  "mcpServers": {
    "apify": {
      "url": "https://mcp.apify.com?tools=humble-echidna/dataset-join"
    }
  }
}
```

To use an [Apify API token](https://console.apify.com/settings/integrations) instead of signing in, add
`"headers": {"Authorization": "Bearer <APIFY_TOKEN>"}` next to `url`.

An agent that has run two actors can pass both datasets, e.g. `{"datasetId": "<leads>", "lookupDatasetId":
"<stacks>", "joinOn": ["website = url"], "matchAs": "domain"}`, and get one combined result.

### Who it's for

Anyone who runs more than one scraper on Apify and needs the results in one table: lead lists enriched with
technology, contact or company data; product lists with prices from two shops; a fresh scrape checked against the
records you already have (anti join).

### Why this one?

- **VLOOKUP for datasets, without a spreadsheet.** Datasets and CSV/TSV, JSON, JSON Lines and Excel files, in any
  combination, with nested fields and multi-field keys.
- **Website domains match out of the box.** URLs, bare domains and email addresses line up by domain, the usual key
  between lead scrapers and website tools.
- **Never overwrites your data.** Clashing names are added as `lookup_<name>`; every row has the same columns.
- **One flat price, no usage bill:** $1.00 per 1,000 rows ($0.90 on Starter), matched or not, about half the price of
  the alternative for joined rows. Lookup rows are free.
- **Fails fast on a typo.** If no row has the join field, the run stops before writing (and charging) anything and
  lists the fields the first row has.
- **Polite and safe with files.** It identifies itself honestly (User-Agent `HumbleEchidnaApify`), follows each
  site's robots.txt, and only requests public web addresses on the standard ports (80 and 443).

### Limits

- Lookup data: up to 200,000 rows, held in memory (at most half of the run's memory; the status says to give the run
  more memory, keep fewer lookup fields or filter first). Main data: up to 1,000,000 rows, read as a stream.
- Files: up to 200 MB (after decompression), public web addresses on ports 80 and 443; Excel's first sheet only.
- A single output row can be up to 5 MB; larger ones are skipped and counted.

### FAQ

#### Which lookup row is used when several have the same key?

The first one in the lookup data's order, as VLOOKUP does. Choose **one row per match** to get them all.

#### Why didn't my rows match?

Check that **Join on** names each side's field as written (case-sensitive; dots for nested fields). Values that
differ in more than case and spaces don't match under **Text**: a URL and a domain need **Website domain**. The status
counts the main rows without a key value.

#### Can I find rows that are NOT in another dataset?

Yes: **Which rows to keep** → **only main rows without a match** (anti join), e.g. leads not yet in your CRM export.

#### Is it legal to join datasets and files with it?

It only processes data you choose: your own Apify datasets, and files at addresses you supply, fetched politely
(robots.txt honoured, honest User-Agent). Make sure you're allowed to use the files you point it at. The example data
is the US Treasury's Fiscal Data API, which is "offered free, without restriction" for commercial and non-commercial
use.

#### Something that used to work now fails. Why?

The run log and the status say what went wrong. Please open an issue with the input you used.

### Related actors

| Actor | Use it when |
|---|---|
| [Dataset Transformer](https://apify.com/humble-echidna/dataset-transform) | You want to filter, dedupe or reshape either side before joining, or the result after, or save it as CSV. |
| [Dataset Diff](https://apify.com/humble-echidna/dataset-diff) | You want what changed between two runs of the same data, not two different datasets combined. |
| [Tech Stack Detector](https://apify.com/humble-echidna/tech-stack-detector) | You need each lead's website technologies to join back onto your leads (it reads their dataset too). |

### Feedback and support

Found a bug, or need a join that isn't here? Open an issue on the **Issues** tab with the input you used.

### Versions

Current version: **0.1**. See the Changelog tab for what changed in each version.

# Changelog

This Actor's version history is a separate document: https://apify.com/humble-echidna/dataset-join/changelog.md

# Actor input Schema

## `datasetId` (type: `string`):

The rows to add fields to (every output row is one of these): the id or name of one of your own Apify datasets, e.g. a Google Maps or product scraper's results (or pick one in the form). To run this actor after a scraper in an integration, `{{resource.defaultDatasetId}}`. Read-only, with your own account's access. When it's set, fileUrl is ignored.

## `fileUrl` (type: `string`):

Used only when datasetId is empty: a direct link to a CSV/TSV, JSON (a list of objects), JSON Lines or Excel (.xlsx, first sheet) file on a public web address (http/https, ports 80 and 443), up to 200 MB. The default is the US Treasury's official exchange rates for 2025-12-31, the example's main data.

## `lookupDatasetId` (type: `string`):

The rows with the fields to add (like the second table of a VLOOKUP): one of your own Apify datasets, e.g. Tech Stack Detector's or a contact scraper's results. Read-only. When it's set, lookupFileUrl is ignored.

## `lookupFileUrl` (type: `string`):

Used only when lookupDatasetId is empty: a direct link to the lookup CSV, JSON, JSONL or Excel file. The default is the same Treasury table a year earlier (2024-12-31), the example's lookup data; it's only used together with the example's main data, never with your own.

## `joinOn` (type: `array`):

The field that links a main row to its lookup row(s), one pair per line: `field` when both sides call it the same (e.g. `url`), or `main field = lookup field` when they don't (e.g. `website = domain`). Several lines join on all of them together (`name` and `city`). Dots reach nested fields (`address.postalCode`). The default is the example's key.

## `matchAs` (type: `string`):

How key values are compared. `text` (the default): upper/lower case, spaces at either end or repeated, and how accents are encoded are ignored; 12 and "12" match. `exact`: only number formatting is ignored. `domain`: the website's domain, so https://www.acme.com/about, acme.com and info@acme.com all match acme.com (for joining leads on websites).

## `joinType` (type: `string`):

`left` (the default): every main row, with the lookup fields filled in when it matched and null when it didn't. `inner`: only main rows that matched. `anti`: only main rows that didn't match, with no lookup fields (e.g. leads not in your CRM yet).

## `multipleMatches` (type: `string`):

`first` (the default): like VLOOKUP, the first lookup row with the key wins, so each main row gives exactly one output row. `all`: one output row per matching lookup row, like a SQL join (e.g. a company with three contacts gives three rows).

## `lookupFields` (type: `array`):

The lookup fields to add to each main row, one per line: `field`, or `field -> new name` to rename it (e.g. `technologies -> stack`). Leave empty (the default) to add every lookup field. A field the main row already has is added as `lookup_<name>`, so the main row's values are never overwritten.

## `lookupPrefix` (type: `string`):

Optional text put in front of every added field's name, e.g. `crm_` gives `crm_owner`, `crm_stage`. Default: none.

## `addMatchedField` (type: `boolean`):

Default true: add `joinMatched` (true when the row found a lookup row) to every output row, to filter or count matches later.

## `maxResults` (type: `integer`):

Stop after writing this many rows, e.g. 100. Minimum 1; leave empty (the default) for no limit. The run also stops cleanly at the maximum cost per run you set in the run options, whichever comes first.

## Actor input object example

```json
{
  "fileUrl": "https://api.fiscaldata.treasury.gov/services/api/fiscal_service/v1/accounting/od/rates_of_exchange?format=csv&filter=record_date:eq:2025-12-31&page[size]=300",
  "lookupFileUrl": "https://api.fiscaldata.treasury.gov/services/api/fiscal_service/v1/accounting/od/rates_of_exchange?format=csv&filter=record_date:eq:2024-12-31&page[size]=300",
  "joinOn": [
    "country_currency_desc"
  ],
  "matchAs": "text",
  "joinType": "left",
  "multipleMatches": "first",
  "lookupFields": [
    "exchange_rate -> rateAYearEarlier",
    "record_date -> rateDateAYearEarlier"
  ],
  "addMatchedField": true
}
```

# Actor output Schema

## `results` (type: `string`):

No description

## `runStats` (type: `string`):

No description

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "fileUrl": "https://api.fiscaldata.treasury.gov/services/api/fiscal_service/v1/accounting/od/rates_of_exchange?format=csv&filter=record_date:eq:2025-12-31&page[size]=300",
    "lookupFileUrl": "https://api.fiscaldata.treasury.gov/services/api/fiscal_service/v1/accounting/od/rates_of_exchange?format=csv&filter=record_date:eq:2024-12-31&page[size]=300",
    "joinOn": [
        "country_currency_desc"
    ],
    "lookupFields": [
        "exchange_rate -> rateAYearEarlier",
        "record_date -> rateDateAYearEarlier"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("humble-echidna/dataset-join").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "fileUrl": "https://api.fiscaldata.treasury.gov/services/api/fiscal_service/v1/accounting/od/rates_of_exchange?format=csv&filter=record_date:eq:2025-12-31&page[size]=300",
    "lookupFileUrl": "https://api.fiscaldata.treasury.gov/services/api/fiscal_service/v1/accounting/od/rates_of_exchange?format=csv&filter=record_date:eq:2024-12-31&page[size]=300",
    "joinOn": ["country_currency_desc"],
    "lookupFields": [
        "exchange_rate -> rateAYearEarlier",
        "record_date -> rateDateAYearEarlier",
    ],
}

# Run the Actor and wait for it to finish
run = client.actor("humble-echidna/dataset-join").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "fileUrl": "https://api.fiscaldata.treasury.gov/services/api/fiscal_service/v1/accounting/od/rates_of_exchange?format=csv&filter=record_date:eq:2025-12-31&page[size]=300",
  "lookupFileUrl": "https://api.fiscaldata.treasury.gov/services/api/fiscal_service/v1/accounting/od/rates_of_exchange?format=csv&filter=record_date:eq:2024-12-31&page[size]=300",
  "joinOn": [
    "country_currency_desc"
  ],
  "lookupFields": [
    "exchange_rate -> rateAYearEarlier",
    "record_date -> rateDateAYearEarlier"
  ]
}' |
apify call humble-echidna/dataset-join --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,humble-echidna/dataset-join"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/N7Oblz1db6C0waH21/builds/HwYVOYd3YYpMgybhq/openapi.json
