# Web forms filled from a spreadsheet: a script tested on 50 rows

> Our Playwright script put 50 sample rows into a test form, logged every failure and survived two forced crashes without saving any row twice.

- Published: 2026-10-30
- Author: 28creation Desk
- Format: Guide
- URL: https://28creation.com/articles/fill-web-forms-from-spreadsheet/

## The short read

**Killed twice during a 50-row test on a sample form, our script resumed both times and saved no row twice.**

- On a sample form we built, a Playwright script entered 44 of 50 made-up customers in 36 seconds and logged why the other 6 failed.
- A dry run that fills each form without sending it caught 5 of our 7 planted data problems before anything was saved.

### What you can do with this

> Log each row as submitting before the script clicks and as saved after the reply. Check any row left at submitting by hand instead of resending it, so a crash never creates a duplicate.

---

Playwright, a free browser automation library from Microsoft, can type a spreadsheet into a web form in a few dozen lines. The typing is the easy part. What costs you is a row that fails without anyone noticing, or a script that dies at row 31 and enters rows 1 to 30 again when restarted.

This guide gives you a tested script that fills a form once per CSV row and logs every outcome. It resumes where it stopped without sending a row twice.

We built a sample form and a 50-row file with 7 planted problems, then ran the script, killed it twice in the middle and ran it again. The results, the code and the limits are below. Documentation was read on October 25, 2026.

## Failures are the hard part, not the typing

A person entering rows notices when the page says "Please fill out this field" and fixes the row. A script does not, unless you tell it to look. Three kinds of failure need different handling:

- **The browser refuses the value before sending anything.** An empty required field or a malformed email blocks the submit, and nothing is sent.
- **The server refuses the row.** It answers, but with an error such as a credit limit that needs approval.
- **Nobody knows.** The form was sent, and then the script crashed, the network dropped or the reply never came. The row may or may not be saved.

The third case is where duplicates come from. A script that remembers only the rows it saw succeed will send that row again on the next run, and if the first attempt did reach the server, you now have two customers, two orders or two payments.

The fix is to write down "submitting" before the click and "saved" after the reply. A row whose last entry is "submitting" is the one to check by hand.

## Check for an import or an API before you script a browser

Filling a form through a browser is the last resort, not the first. A script like this breaks when someone renames a button or a field label, because those are what it uses to find things. A CSV import screen or an API does not have that problem. Look in the application's settings, admin area or help pages for "import" before you write any code.

Script the browser when there is no import, when the import skips fields the form has, or when the form runs checks you want applied to every row. Do not use it on sites whose terms forbid automated access, and stop if a CAPTCHA appears: that is the site telling you it does not want scripted entry. Use it on your own company's systems, or on systems where you have permission, with your own account.

Before you start, work out whether it pays. Our [automation payback formula](/articles/automation-payback-formula/) counts the time to build and fix a script against the time it saves. For a one-off job of 20 rows, we would type them.

## Our test: a sample form and 50 rows with 7 planted problems

We wrote a small local web server with a "New customer" form. It has a company name, contact email, country (a dropdown with four countries), customer since (a date field), credit limit (a number field), a newsletter checkbox and notes.

The browser checks the required fields, the email format and the number. The server adds one rule the browser cannot check: a credit limit above 50,000 needs approval. To act like a busy server, every 15th request returns HTTP 503 ("service unavailable") once, and every 7th saved record gets a slow reply of 1.2 seconds.

A second script generated `customers.csv`: 50 made-up companies with `example.com` addresses. One row has notes with commas and quotation marks inside, to test the CSV reading. Seven rows carry a planted problem, and this is what happened to each in the real run:

| Row | Planted problem | What happened |
|---|---|---|
| 7 | Email `orders7@example` (no dot in the domain) | **Saved.** The browser accepts it |
| 14 | Email `orders14 example.com` (no @) | Rejected by the browser, nothing sent |
| 20 | Company name empty | Rejected by the browser, nothing sent |
| 27 | Country `Sverige`, not in the list | Error after a 5-second wait for the option |
| 34 | Date `31/12/2024` | Error: "Malformed value" |
| 41 | Credit limit 75,000 | Sent, rejected by the server (HTTP 422) |
| 46 | Credit limit `2,500` with a comma | Error: "Cannot type text into input[type=number]" |

Row 7 is the one to remember.

A browser's email check only asks for something shaped like `username@domain`, and [MDN's reference](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/email) lists `me@example` among its valid examples. The same page says the check "doesn't necessarily mean the email address exists". So the form's own checks are a floor, not a data quality check. Clean the spreadsheet first; our guides on [CSV imports that mangle zeros and dates](/articles/excel-csv-dates-leading-zeros/) and on [comparing two sheets by ID](/articles/compare-two-excel-sheets/) cover the usual traps.

Row 34 failed because a date field's value is always `yyyy-mm-dd`, whatever the page displays. [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/date) says "the displayed date is formatted based on the locale of the user's browser, but the parsed value is always formatted yyyy-mm-dd". Convert dates to that form in the CSV, not in the script.

## Step 1: install Node.js and Playwright

You need Node.js (we used version 22) and a folder for the job. In that folder, install the library and one browser. Playwright's [library page](https://playwright.dev/docs/library) gives these commands:

```bash
npm init -y
npm i -D playwright
npx playwright install chromium
```

By default Playwright downloads its browsers from Microsoft's CDN and "runs the browsers in headless mode", meaning no window opens. Playwright is free under the [Apache 2.0 license](https://github.com/microsoft/playwright). We ran version 1.63.0 with Chromium on Linux.

## Step 2: map each column to a label on the form

The script finds each field by the text of its label, the words a person sees next to the box. Playwright's [locator guide](https://playwright.dev/docs/locators) recommends user-facing locators like this over CSS selectors, which "are not recommended as the DOM can often change". For form fields it says to use `page.getByLabel()`.

Open the form, note each label exactly and write a map from your CSV's column names to those labels. Mark dropdowns as `select` and checkboxes as `checkbox`.

If you are not sure what a label is called, run `npx playwright codegen https://your-form.example/`. It opens a browser, records what you click and type, and prints the code. Its [codegen page](https://playwright.dev/docs/codegen) says it picks locators by "prioritizing role, text and test id locators".

One trap cost us our first attempt.

Our first version of the test form put each field inside its label, as in `<label>Country <select>...</select></label>`. Playwright's exact label match found no dropdown at all, because the label's text then includes every option ("Country ChooseSweden" in our check). The same field was still found by `page.getByRole('combobox', { name: 'Country', exact: true })`. If `getByLabel` finds nothing for a dropdown, try `getByRole` with the label's words.

You also need four more things from the form: the submit button's text, the address the form sends to (its `action`), where the page shows the result, and the words that mean success. Submit one row by hand and look. Our form shows "Saved. Customer number C-1001" in an element with `role="status"`.

## Step 3: save the script

Save this as `fill-forms.mjs` and change the settings at the top for your form. `KEY` must name a column with a different value in every row, such as an order number or an email address; the log uses it to know what is done. The script refuses to start if that column is missing, empty or repeated, or if a mapped column is missing.

```js
// fill-forms.mjs: fill a web form once per CSV row, log every outcome, resume where it stopped.
// Usage: node fill-forms.mjs customers.csv [--dry-run]
import fs from 'node:fs';
import { chromium } from 'playwright';

const FORM_URL = 'http://localhost:4321/';
const LOG = 'fill-log.jsonl';
const KEY = 'row'; // a column that is unique per row; the log uses it to know what is done
// CSV column -> the form field's visible label, and how to fill it
const FIELDS = {
  company: { label: 'Company name' },
  email: { label: 'Contact email' },
  country: { label: 'Country', kind: 'select' },
  since: { label: 'Customer since' },
  limit: { label: 'Credit limit' },
  newsletter: { label: 'Newsletter', kind: 'checkbox' },
  notes: { label: 'Notes' },
};
const SUBMIT = { role: 'button', name: 'Save customer' };
const POST_URL = '/customers'; // part of the address the form sends to (the form's action)
const RESULT = '[role=status], [role=alert]'; // where the page says saved or why not
const SUCCESS = /^Saved\./;
const RETRIES = 2; // only for 429 and 503, which mean "not processed, try later"

function parseCsv(text) {
  const rows = [[]];
  let field = '', quoted = false;
  for (let i = 0; i < text.length; i++) {
    const c = text[i];
    if (quoted) {
      if (c === '"' && text[i + 1] === '"') { field += '"'; i++; }
      else if (c === '"') quoted = false;
      else field += c;
    } else if (c === '"') quoted = true;
    else if (c === ',') { rows.at(-1).push(field); field = ''; }
    else if (c === '\n' || c === '\r') {
      if (c === '\r' && text[i + 1] === '\n') i++;
      rows.at(-1).push(field); field = ''; rows.push([]);
    } else field += c;
  }
  if (field || rows.at(-1).length) rows.at(-1).push(field);
  const [head, ...body] = rows.filter((r) => r.some((v) => v !== ''));
  return body.map((r) => Object.fromEntries(head.map((h, i) => [h.trim(), r[i] ?? ''])));
}

const log = (entry) => fs.appendFileSync(LOG, JSON.stringify({ ...entry, at: new Date().toISOString() }) + '\n');

// The last thing the log says about each key
function lastStatus() {
  const status = new Map();
  if (!fs.existsSync(LOG)) return status;
  for (const line of fs.readFileSync(LOG, 'utf8').split('\n')) {
    if (!line.trim()) continue;
    try { const e = JSON.parse(line); status.set(e.key, e.status); } catch { /* half-written last line */ }
  }
  return status;
}

async function fillRow(page, row) {
  await page.goto(FORM_URL);
  for (const [col, f] of Object.entries(FIELDS)) {
    const field = page.getByLabel(f.label, { exact: true });
    const value = (row[col] ?? '').trim();
    if (f.kind === 'select') await field.selectOption({ label: value });
    else if (f.kind === 'checkbox') await field.setChecked(/^(yes|true|1|x)$/i.test(value));
    else await field.fill(value);
  }
  // The browser's own checks (required, email, number); a blocked submit sends nothing
  return page.locator('form').evaluate((form) =>
    [...form.elements].filter((el) => el.willValidate && !el.checkValidity())
      .map((el) => `${el.name}: ${el.validationMessage}`).join('; '));
}

const dryRun = process.argv.includes('--dry-run');
// Excel's "CSV UTF-8" starts with a byte order mark that would hide the first column's name
const rows = parseCsv(fs.readFileSync(process.argv[2], 'utf8').replace(/^\uFEFF/, ''));
const keys = rows.map((r) => r[KEY]);
const missing = [KEY, ...Object.keys(FIELDS)].filter((c) => rows.length && !(c in rows[0]));
if (missing.length) throw new Error(`CSV has no column: ${missing.join(', ')}`);
if (keys.some((k) => !k) || new Set(keys).size !== keys.length) throw new Error(`every row needs its own ${KEY}`);
const done = lastStatus();
const browser = await chromium.launch();
const page = await browser.newPage();
page.setDefaultTimeout(5000);
const tally = {};
const count = (s) => (tally[s] = (tally[s] ?? 0) + 1);

for (const row of rows) {
  const key = row[KEY];
  if (done.get(key) === 'saved') { count('skipped, saved before'); continue; }
  if (['submitting', 'unknown'].includes(done.get(key))) {
    // Stopped after sending but before the reply was logged: it may or may not be saved.
    count('unknown, check by hand');
    console.log(`row ${key}: was being submitted when the last run stopped; check it by hand, then log it`);
    continue;
  }
  for (let attempt = 0; ; attempt++) {
    let sent = false;
    try {
      const invalid = await fillRow(page, row);
      if (invalid) { log({ key, status: 'rejected', detail: `browser: ${invalid}` }); count('rejected'); break; }
      if (dryRun) { count('would submit'); break; }
      log({ key, status: 'submitting' });
      sent = true;
      const [response] = await Promise.all([
        page.waitForResponse((r) => r.request().method() === 'POST' && r.url().includes(POST_URL), { timeout: 30000 }),
        page.getByRole(SUBMIT.role, { name: SUBMIT.name }).click(),
      ]);
      const message = (await page.locator(RESULT).first().textContent({ timeout: 30000 })).trim();
      const code = response.status();
      const busy = [429, 503].includes(code);
      if (busy && attempt < RETRIES) {
        log({ key, status: 'retrying', detail: `HTTP ${code}` });
        await page.waitForTimeout(3000 * (attempt + 1));
        continue;
      }
      // Still busy: try next run. Other 5xx replies (502, 504) can come after the server saved the row.
      const status = SUCCESS.test(message) ? 'saved' : busy ? 'error' : code >= 500 ? 'unknown' : 'rejected';
      log({ key, status, detail: `HTTP ${code}: ${message}` });
      count(status);
    } catch (err) {
      // Before sending: Playwright refused a value (no such option, bad date, text in a number field).
      // After sending: no reply in time, so the row may be saved; never resend it blindly.
      const status = sent ? 'unknown' : 'error';
      log({ key, status, detail: err.message.split('\n')[0] });
      count(status);
    }
    break;
  }
}
await browser.close();
console.log(tally);
```

Three choices in it matter more than the rest.

**It sets a 5-second limit on every action.** Playwright's actions wait for the page to be ready, and its [API reference](https://playwright.dev/docs/api/class-locator) says `selectOption` "waits until all specified options are present". An option that will never appear, like row 27's unknown country, is waited for until the timeout runs out.

Without our setting, a separate check with the same library gave up after 30 seconds. With it, the row costs 5 seconds and an error line. Raise the limit if your form is slow to load.

**It starts waiting for the server's reply before it clicks.** Playwright's [network guide](https://playwright.dev/docs/network) shows the same pattern: create the `waitForResponse` promise first, then click. A wait started after the click could miss a reply that arrives first.

The wait only accepts a POST to `POST_URL`, because many pages send other requests of their own, such as analytics, that must not be taken for the form's reply. Reading the HTTP status also tells the script whether the server was busy (503, safe to retry) or something else went wrong.

**It retries only 429 and 503.** [RFC 6585](https://www.rfc-editor.org/rfc/rfc6585.html) defines 429 as "too many requests in a given amount of time". [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html) defines 503 as a server "currently unable to handle the request due to a temporary overload or scheduled maintenance". A 502 or 504, by the same RFC, comes from a gateway or proxy that got a bad answer, or no timely answer, from the server behind it. That server may already have saved the row, so the script marks those rows unknown instead of sending them again. A row still getting 429 or 503 after two retries is logged as an error and tried on the next run.

## Step 4: do a dry run that sends nothing

Run `node fill-forms.mjs customers.csv --dry-run`. It opens the form for each row, fills every field and runs the browser's own checks, then moves on without clicking submit. Every refusal goes to `fill-log.jsonl` with the reason.

On our file the dry run took 13.1 seconds for 50 rows and saved nothing on the server. It caught 5 of the 7 planted problems. They were the missing @ (Chromium's message: "Please include an '@' in the email address"), the empty company name, the unknown country, the malformed date and the comma in the number.

It could not catch row 7's dotless email, which the browser accepts, or row 41's credit limit, which only the server checks. Fix what it finds, delete the log, and run it again until it reports nothing.

## Step 5: run it for real and read the log

Run `node fill-forms.mjs customers.csv` without the flag. Each row gets one or more lines in the log, which is a JSON Lines file: one JSON object per line, readable in any text editor. Row 16 hit one of the busy replies; its four lines from our run show the retry:

```json
{"key":"16","status":"submitting","at":"2026-10-25T07:38:11.861Z"}
{"key":"16","status":"retrying","detail":"HTTP 503","at":"2026-10-25T07:38:11.965Z"}
{"key":"16","status":"submitting","at":"2026-10-25T07:38:15.079Z"}
{"key":"16","status":"saved","detail":"HTTP 200: Saved. Customer number C-1015","at":"2026-10-25T07:38:15.207Z"}
```

An uninterrupted run on our file took 36.1 seconds and saved 44 rows, rejected 3 and logged 3 errors, which matches the table above. The three 503 replies were each retried once and saved on the second try.

About 21 of the 36 seconds were waits we had built in: 9 seconds of retry pauses, 7.2 seconds of deliberately slow replies and 5 seconds on the unknown country. That leaves about 15 seconds for the rest of the work on 50 rows, on a form served from the same machine. A real site across the internet will be slower, and you should not try to make it faster: many sites limit how many requests one user may send.

We did not time a person entering the same 50 rows, so we cannot give you a speed-up figure.

## Step 6: resume after a crash without saving twice

To test resuming, we started the script and killed it as soon as the server had saved 25 records. We used `kill -9`, which gives a program no chance to clean up.

The server's 25th record was row 28, but the log's last line for row 28 said "submitting": the kill came after the form was sent and before the reply was logged. We started the script again and killed it once the server held 28 records. That kill landed the same way, while the server was holding its slow reply for row 31.

The third run printed this and carried on:

```
row 28: was being submitted when the last run stopped; check it by hand, then log it
row 31: was being submitted when the last run stopped; check it by hand, then log it
{
  'skipped, saved before': 26,
  rejected: 3,
  error: 3,
  'unknown, check by hand': 2,
  saved: 16
}
```

The server ended with 44 records for 44 different companies: no duplicates. Rows 28 and 31 were there once each. A script that skipped only rows logged as saved would, by its own logic, have sent both a second time.

To clear a row like 28 or 31, look it up in the application. If it is there, add a line to the log by hand, such as `{"key":"28","status":"saved","detail":"checked by hand"}`. If it is not, add a line with `"status":"error"` and the next run will try it again. Rows logged as `rejected` or `error` are always tried again, so fix them in the CSV before the next run.

One gap remains. The "submitting" line is written before the click, so a script that crashes or is killed always leaves it behind. A power cut or an operating system crash is different: the last lines handed to the system may not have reached the disk yet. For money or orders, keep a reference column such as an invoice number that the application shows, and search for it after any crash.

## Step 7: forms behind a login

Most real forms sit behind a sign-in. Playwright can save a signed-in session to a file and reuse it.

The codegen command takes `--save-storage=auth.json`: you sign in once in the window it opens, and it saves cookies, local storage and IndexedDB to the file. Its [documentation](https://playwright.dev/docs/codegen) says to "only use the auth.json locally as it contains sensitive information". The [authentication guide](https://playwright.dev/docs/auth) warns that such a file "may contain sensitive cookies and headers that could be used to impersonate you".

To use it in the script, change `browser.newPage()` to `(await browser.newContext({ storageState: 'auth.json' })).newPage()`. We did not test this part.

Keep that file out of shared drives and version control, and delete it when the job is done. Never put a password in the script. When the session runs out, the site will most likely show its sign-in page instead of the form. The script then finds none of its fields, and each row logs an error without sending anything.

## When this approach is the wrong tool

- **Large or regular volumes.** In our view a few hundred rows once a month is fine. Thousands a day belong in an import or an API, which the vendor supports and which survives a redesign.
- **Forms that change often.** Each label change means editing the map. Run the dry run first every time.
- **Multi-page wizards and file uploads.** Playwright handles them, but each page needs its own code.
- **Anything that moves money with no reference number.** Without a way to check whether a row went through, a crash leaves you guessing.

Before you schedule the script to run unattended, read our piece on [scheduled scripts that fail silently](/articles/tiny-automation-in-code/). It covers alerts for failed runs and checks that a run happened at all.

## How we made this

On October 25, 2026, we read Playwright's documentation, MDN's pages on email and date inputs, and the RFCs that define HTTP status codes 429, 502, 503 and 504. The same day we built a local sample form and a 50-row CSV of made-up customers with 7 planted problems. We ran the script above on them with Playwright 1.63.0, Chromium and Node 22 on Linux: a dry run, a run killed twice with `kill -9` and resumed, and an uninterrupted timed run. We did not test a real website, a login, Windows or macOS, and we did not time manual entry.

## Sources

- Playwright: [Library installation](https://playwright.dev/docs/library), [Locators](https://playwright.dev/docs/locators), [Input](https://playwright.dev/docs/input), [Locator API](https://playwright.dev/docs/api/class-locator), [Actionability](https://playwright.dev/docs/actionability), [Network](https://playwright.dev/docs/network), [Codegen](https://playwright.dev/docs/codegen), [Authentication](https://playwright.dev/docs/auth)
- [Playwright repository and license](https://github.com/microsoft/playwright)
- HTTP status codes: [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html) (502, 503, 504), [RFC 6585](https://www.rfc-editor.org/rfc/rfc6585.html) (429)
- MDN: [input type="email"](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/email), [input type="date"](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/date)
