FormData in JavaScript: read, change and convert form values

new FormData(form) turns a form into a list of name and value pairs. Knowing which fields make that list, and how to read it back, removes most form bugs.

FormData is a built-in JavaScript object that holds a list of name and value pairs. new FormData(form) fills that list from a form: every control that has a name and is not disabled adds one entry.

You read it with get() and getAll(), and change it with set(), append() and delete().

Try it. Type, tick boxes, pick a file. The panel rebuilds the FormData on every change and lists what is inside.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>FormData inspector</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .wrap { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
  @media (max-width: 560px) { .wrap { grid-template-columns: 1fr; } }
  form, .panel { background: #fff; border-radius: 12px; padding: 14px; box-shadow: 0 2px 10px rgba(0, 0, 0, .08); }
  label { display: block; margin: 0 0 10px; font-size: 14px; }
  input[type=text], select { width: 100%; box-sizing: border-box; padding: 6px 8px; font: inherit; }
  .checks label { display: inline-block; margin-right: 12px; }
  small { color: #6b7280; }
  h3 { margin: 0 0 8px; font-size: 15px; }
  ul { margin: 0; padding: 0; list-style: none; font: 13px/1.5 ui-monospace, Consolas, monospace; }
  li { padding: 3px 6px; border-bottom: 1px solid #eef0f3; word-break: break-all; }
  li b { color: #2563eb; }
  .note { white-space: pre-line; margin-top: 10px; font-size: 13px; background: #eef6ff; border-radius: 8px; padding: 8px; }
</style>
</head>
<body>
<div class="wrap">
  <form id="form">
    <label>Name <input type="text" name="name" value="Ada"></label>
    <div class="checks">Topics<br>
      <label><input type="checkbox" name="topic" value="html" checked> html</label>
      <label><input type="checkbox" name="topic" value="css" checked> css</label>
      <label><input type="checkbox" name="topic" value="js"> js</label>
    </div>
    <label>Tools (Ctrl/Cmd for several)
      <select name="tool" multiple size="3">
        <option selected>editor</option><option>browser</option><option selected>terminal</option>
      </select>
    </label>
    <label>File <input type="file" name="upload"></label>
    <label>Plan <input type="text" name="plan" value="free" disabled> <small>disabled</small></label>
    <label>Nickname <input type="text" value="no name attribute"> <small>no name</small></label>
  </form>

  <div class="panel">
    <h3>new FormData(form)</h3>
    <ul id="out"></ul>
    <div class="note" id="note"></div>
  </div>
</div>

<script>
  const form = document.getElementById('form');
  const out = document.getElementById('out');
  const note = document.getElementById('note');

  // Show a File as its name and size, everything else as text
  const show = (v) => v === null ? 'null'
    : v instanceof File ? `File("${v.name}", ${v.size} bytes)` : `"${v}"`;

  function render() {
    const data = new FormData(form);
    out.innerHTML = '';
    for (const [key, value] of data) {        // same as data.entries()
      const li = document.createElement('li');
      li.innerHTML = `<b>${key}</b> = `;
      li.append(show(value));                  // append as text, not HTML
      out.append(li);
    }
    note.textContent = `get('topic') → ${show(data.get('topic'))}
` +
      `getAll('topic') → [${data.getAll('topic').map(show).join(', ')}]`;
  }

  form.addEventListener('input', render);
  form.addEventListener('change', render);
  render();
</script>
</body>
</html>
The panel calls new FormData(form) on every input. The disabled field and the field without a name never appear.

This guide is about the object in your script. For what the browser sends when a form submits on its own, see HTML form submit.

What new FormData(form) collects

The constructor walks the form's controls in page order and keeps some of them. The key is the name attribute, never the id.

Named, enabled controls become entries. Everything else is left out.
Named, enabled controls become entries. Everything else is left out.
Control What ends up in FormData
Text, email, textarea One entry, even when empty ("")
Checkbox or radio One entry only when checked. Without a value attribute, the value is "on"
<select multiple> One entry per selected option
File input One File per chosen file. With no file chosen, one empty File with no name
readonly field Included
disabled field, or any field inside a disabled fieldset Left out
Buttons Left out, unless passed as the second argument: new FormData(form, submitter)

Fields placed outside the <form> tag but linked to it with form="id" are included too.

get, getAll, has, set, append, delete

A FormData is a list, not an object with properties, so one name can hold several values. The methods reflect that.

Method What it does
get(name) First value for the name, or null
getAll(name) Every value as an array, or []
has(name) true if at least one entry exists
set(name, value) Replaces all values for the name with one
append(name, value) Adds another value and keeps the old ones
delete(name) Removes every entry with the name

Values you add become strings. data.set('count', 3) stores "3". The one exception is a Blob or File, which stays a file:

const data = new FormData(form);
data.get('topic');          // "html" – only the first
data.getAll('topic');       // ["html", "css"]
data.set('count', 3);       // stored as "3"
data.append('photo', blob, 'photo.png');  // a file with a filename

Checkboxes, multiple selects and files

Unchecked boxes send nothing. There is no false entry. So data.has('newsletter') is how you read a single checkbox as yes or no.

Boxes that share a name give one entry each. get() returns only the first ticked one, which looks like lost data. Use getAll() and you get the full array. A <select multiple> behaves the same way.

Files arrive as File objects with name, size and type.

A file input with nothing chosen still adds an entry: a File with an empty name and size 0. Check file.size > 0 before treating it as an upload. For a drop zone, see drag and drop file upload.

FormData to JSON, object or query string

Most APIs want JSON, and JSON.stringify(data) gives "{}". You have to turn the entries into a plain object first. The obvious one-liner has a catch.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>FormData conversions</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  form, .panel { background: #fff; border-radius: 12px; padding: 14px; box-shadow: 0 2px 10px rgba(0, 0, 0, .08); margin-bottom: 12px; }
  label { margin-right: 12px; font-size: 14px; white-space: nowrap; }
  input[type=text] { padding: 5px 7px; font: inherit; width: 140px; }
  .btns { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 10px; }
  button { font: inherit; font-size: 14px; padding: 7px 11px; border: 1px solid #cbd2dc; border-radius: 8px; background: #fff; cursor: pointer; }
  button.on { background: #1d2330; color: #fff; border-color: #1d2330; }
  pre { margin: 0; padding: 10px; background: #f7f8fa; border-radius: 8px; font: 13px/1.5 ui-monospace, Consolas, monospace; white-space: pre-wrap; word-break: break-all; }
  .lost { margin-top: 8px; font-size: 13px; padding: 8px; border-radius: 8px; }
  .lost.bad { background: #fff1ec; color: #9a3412; }
  .lost.good { background: #edf9f1; color: #0f5132; }
</style>
</head>
<body>
<form id="form">
  <label>Name <input type="text" name="name" value="Ada"></label><br><br>
  <label><input type="checkbox" name="topic" value="html" checked> html</label>
  <label><input type="checkbox" name="topic" value="css" checked> css</label>
  <label><input type="checkbox" name="topic" value="js" checked> js</label>
</form>

<div class="panel">
  <div class="btns">
    <button data-mode="plain">Object.fromEntries</button>
    <button data-mode="arrays">getAll-aware object</button>
    <button data-mode="query">URLSearchParams</button>
  </div>
  <pre id="out"></pre>
  <div class="lost" id="lost"></div>
</div>

<script>
  const form = document.getElementById('form');
  const out = document.getElementById('out');
  const lost = document.getElementById('lost');
  let mode = 'plain';

  // Keys that appear more than once become arrays; single keys stay strings
  function toObject(data) {
    const obj = {};
    for (const key of new Set(data.keys())) {
      const all = data.getAll(key);
      obj[key] = all.length > 1 ? all : all[0];
    }
    return obj;
  }

  function render() {
    const data = new FormData(form);
    const total = [...data].length;
    lost.className = 'lost good';

    if (mode === 'plain') {
      const obj = Object.fromEntries(data);   // a repeated key keeps only its last value
      out.textContent = JSON.stringify(obj, null, 2);
      const kept = Object.keys(obj).length;
      const dropped = [...data].filter(([k, v]) => obj[k] !== v).map(([k, v]) => `${k}=${v}`);
      lost.className = dropped.length ? 'lost bad' : 'lost good';
      lost.textContent = dropped.length
        ? `${total} entries in, ${kept} keys out. Lost: ${dropped.join(', ')}`
        : `${total} entries in, nothing lost.`;
    } else if (mode === 'arrays') {
      out.textContent = JSON.stringify(toObject(data), null, 2);
      lost.textContent = `${total} entries in, all kept. Repeated keys became arrays.`;
    } else {
      const query = new URLSearchParams(data).toString();
      out.textContent = query;
      lost.textContent = `${total} entries in, ${total} pairs out. Repeated keys stay repeated.`;
    }
    document.querySelectorAll('button').forEach((b) => b.classList.toggle('on', b.dataset.mode === mode));
  }

  document.querySelectorAll('button').forEach((b) =>
    b.addEventListener('click', () => { mode = b.dataset.mode; render(); }));
  form.addEventListener('input', render);
  render();
</script>
</body>
</html>
Switch between the three conversions. With all three boxes ticked, Object.fromEntries keeps only one.

Object.fromEntries(data) writes each pair into an object in order. When a name repeats, the later value overwrites the earlier one, so only the last tick survives.

Four entries in. Object.fromEntries keeps two keys and drops two values; a getAll loop keeps all four.
Four entries in. Object.fromEntries keeps two keys and drops two values; a getAll loop keeps all four.

To keep everything, loop over the unique keys and use getAll:

function toObject(data) {
  const obj = {};
  for (const key of new Set(data.keys())) {
    const all = data.getAll(key);
    obj[key] = all.length > 1 ? all : all[0];
  }
  return obj;
}

If a field can hold one or several values, always read it with getAll so it is an array every time. The survey below does that.

For a query string, new URLSearchParams(data) keeps repeated keys as repeated pairs, such as topic=html&topic=css. It cannot carry files: a File becomes the text [object File].

Why console.log shows an empty FormData

The entries are not properties on the object. Logging data itself may print FormData {}, which looks empty even when the form is full. Log the entries instead:

console.log([...data]);            // [["name","Ada"], ["topic","html"], ...]
for (const [key, value] of data) console.log(key, value);

If that list is really empty, the fields have no name, or they are disabled.

Adding fields and sending it with fetch

set and append add values the user never typed, such as a timestamp or a computed total.

There is also a formdata event: it fires on the form each time a FormData is built from it, and event.formData can be changed there, so every copy gets the extra field.

form.addEventListener('formdata', (event) => {
  event.formData.append('source', 'pricing-page');
});

To send it, pass the object as the body of fetch and do not set a Content-Type header:

fetch('/api/signup', { method: 'POST', body: data });
Set the header by hand and the boundary is lost. Leave it out and the browser writes both.
Set the header by hand and the boundary is lost. Leave it out and the browser writes both.

A multipart body separates fields with a boundary string. The browser picks that string and writes it into the header. A hand-written multipart/form-data header has no boundary, so the server cannot split the fields.

If the server wants JSON instead, send JSON.stringify(toObject(data)) with Content-Type: application/json. That header is fine to set, because JSON has no boundary.

A finished example: a survey form

The survey stops the real submit, builds a FormData, adds two computed fields and shows the JSON it would send. Nothing leaves the page.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Survey form with FormData</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  form, .panel { background: #fff; border-radius: 12px; padding: 16px; box-shadow: 0 2px 10px rgba(0, 0, 0, .08); max-width: 520px; margin: 0 auto 12px; }
  h2 { margin: 0 0 12px; font-size: 18px; }
  fieldset { border: 1px solid #e1e4ea; border-radius: 8px; margin: 0 0 12px; padding: 8px 12px 10px; }
  legend { font-size: 14px; font-weight: 600; padding: 0 4px; }
  fieldset label { display: inline-block; margin: 4px 12px 0 0; font-size: 14px; }
  .field { display: block; font-size: 14px; margin-bottom: 12px; }
  .field input, textarea { display: block; width: 100%; box-sizing: border-box; margin-top: 4px; padding: 7px 8px; font: inherit; }
  textarea { height: 60px; resize: vertical; }
  button { font: inherit; padding: 9px 16px; border: 0; border-radius: 8px; background: #2563eb; color: #fff; cursor: pointer; }
  pre { margin: 0; padding: 10px; background: #f7f8fa; border-radius: 8px; font: 13px/1.5 ui-monospace, Consolas, monospace; white-space: pre-wrap; word-break: break-all; }
  .muted { color: #6b7280; font-size: 13px; margin: 0 0 8px; }
</style>
</head>
<body>
<form id="survey">
  <h2>Quick survey</h2>
  <fieldset>
    <legend>How useful was the guide?</legend>
    <label><input type="radio" name="rating" value="1" required> 1</label>
    <label><input type="radio" name="rating" value="2"> 2</label>
    <label><input type="radio" name="rating" value="3"> 3</label>
    <label><input type="radio" name="rating" value="4"> 4</label>
    <label><input type="radio" name="rating" value="5"> 5</label>
  </fieldset>
  <fieldset>
    <legend>What did you use?</legend>
    <label><input type="checkbox" name="used" value="demos"> Live demos</label>
    <label><input type="checkbox" name="used" value="code"> Code</label>
    <label><input type="checkbox" name="used" value="table"> Fix table</label>
  </fieldset>
  <label class="field">Email (optional) <input type="email" name="email"></label>
  <label class="field">Comment <textarea name="comment"></textarea></label>
  <button type="submit">Send</button>
</form>

<div class="panel">
  <p class="muted">JSON that would be sent (nothing leaves this page):</p>
  <pre id="out">Fill in the form and press Send.</pre>
</div>

<script>
  const form = document.getElementById('survey');
  const out = document.getElementById('out');

  // Repeated keys (the checkboxes) become arrays
  function toObject(data) {
    const obj = {};
    for (const key of new Set(data.keys())) {
      const all = data.getAll(key);
      obj[key] = all.length > 1 ? all : all[0];
    }
    return obj;
  }

  form.addEventListener('submit', (event) => {
    event.preventDefault();                       // stay on the page
    const data = new FormData(form);

    // Computed fields that are not inputs in the form
    data.set('usedCount', data.getAll('used').length);
    data.append('sentAt', new Date().toISOString());

    const obj = toObject(data);
    obj.used = data.getAll('used');               // always an array, even with 0 or 1 ticks
    out.textContent = JSON.stringify(obj, null, 2);
  });
</script>
</body>
</html>
Submit is intercepted. The script adds usedCount and sentAt, turns the checkboxes into an array and prints the JSON.
  • Stop the page load: event.preventDefault() in the submit listener.
  • Computed fields: set('usedCount', ...) and append('sentAt', ...). Note that usedCount comes out as the string "2".
  • Always an array: used is read with getAll, so zero, one and three ticks all give an array.

When it does not work

What you see Cause Fix
A field is missing No name, or the field (or its fieldset) is disabled Add a name. Use readonly to lock a value that must still be included
An unchecked box is missing Unchecked boxes add no entry Read it with has(name)
console.log shows FormData {} Entries are not properties Log [...data] or loop with for...of
JSON.stringify gives {} Same reason Convert to an object first
Only one checkbox value arrives get() or Object.fromEntries kept one value Use getAll() for that key
The server sees no fields Content-Type set by hand, no boundary Remove the header and pass FormData as the body
TypeError from new FormData(el) el is not a <form>, such as a div or null Pass the form element, or start empty and append
An empty file entry The file input had nothing chosen Check file.size > 0

A form is easier to check when someone can fill it in. A screenshot shows the fields but not what the script builds from them, and an .html attachment may open as plain code on a phone.

To send the working version, paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, so the people you send it to can submit the form and see the JSON themselves. If you change the code later, the same link shows the new version.

Questions people ask

Why is my FormData empty?

Two common reasons. Logging the object itself may show FormData {} even when it holds entries, so loop over it or log [...data] instead. If the entries really are missing, the fields have no name attribute or are disabled.

What is the difference between get and getAll?

get(name) returns the first value for that name, or null if there is none. getAll(name) returns every value as an array, which is an empty array if there is none. Use getAll for checkboxes that share a name and for select multiple.

How do I convert FormData to JSON?

Object.fromEntries(data) works when every name appears once. If a name repeats, it keeps only the last value. Build the object with getAll for each unique key instead, then pass it to JSON.stringify.

Should I set the Content-Type header when sending FormData with fetch?

No. Pass the FormData as the body and leave Content-Type out. The browser writes multipart/form-data together with the boundary string the server needs to split the fields.

Can I create FormData without a form?

Yes. new FormData() with no argument gives an empty object, and append fills it. Passing anything other than a form element, such as a div, throws a TypeError.

Keep reading