Route Form file uploads into the right Drive folder

TL;DR — Google Form uploads all land in one folder. Use onFormSubmit to read the upload file ids, pick the destination from an answer (client, project, type), get-or-create the folder path, and move each file — plus a soft NitroGAS note for moveFileToFolder.

File-upload questions are one of the best things about Google Forms, right up until week three. Every receipt, contract, and headshot is sitting in one Form Name (File responses) folder, and somebody spends Friday afternoon dragging them into client folders.

The Form already asks which client it’s for. Let the script do the dragging.

TL;DR

  • Installable on form submit trigger on the linked spreadsheet
  • Read the upload answer (Drive URLs) → file ids
  • Pick the destination path from another answer (e.g. Client / Uploads / 2026-11)
  • Get-or-create that folder path, then move each file there (idempotent)
  • Log what moved where; fail loud on unknown clients instead of guessing
  • Soft helper names: moveFileToFolder, getOrCreateFolderPath, extractDriveFileId (snippets + NitroGAS catalog)

This guide is about filing uploads. If you also need to check answers before they reach your master tab, that’s a separate step. Validate Form answers before they hit your master sheet covers that; run it first.

Using the Apps Script editor a lot? NitroGAS drops free themes & snippets right into script.google.com — optional Co-Pilot when you want a boost.

How uploads show up in the trigger

When the Form is linked to a spreadsheet, a file-upload answer arrives in e.namedValues as one or more Drive URLs, comma-separated:

e.namedValues['Receipt'] → ['https://drive.google.com/open?id=1AbC..., https://drive.google.com/open?id=1XyZ...']

The files are already in Drive, owned by the Form owner, inside the Form’s upload folder. Your job is to move them.

(If your trigger is on the Form instead of the sheet, e.response.getItemResponses() gives you the upload item, and getResponse() on it returns an array of file ids directly. The rest of this guide is the same.)

What not to do

  • Don’t copy files and leave the originals behind: you’ll double your Drive usage and nobody will know which copy is real
  • Don’t build folder names straight from free-text answers (“Acme”, “ACME Inc”, “acme ”); use a dropdown or map to a canonical name
  • Don’t rely on a simple onFormSubmit: moving files needs authorization, so use an installable trigger
  • Don’t swallow “client not found”: unfiled uploads should be visible, not silently parked

Workflow

1. Form: make "Client" a dropdown (or map answers → canonical names in a Config tab) 2. Script Property: uploadsRootId = the Drive folder that holds all client folders 3. Installable trigger: From spreadsheet → On form submit → routeUploads 4. routeUploads: answers → path → folder → move each file → log 5. Submit a test response with two files; check both moved

The handler

var UPLOAD_QUESTION = 'Receipt'; // the file-upload question title var CLIENT_QUESTION = 'Client'; // the answer that decides the folder function routeUploads(e) { var answers = e.namedValues; var client = String((answers[CLIENT_QUESTION] || [''])[0]).trim(); if (!client) throw new Error('routeUploads: no Client answer on this response'); // Free-text answer? Swap in canonicalClient_(client) from the next section. // Build the destination: Client / Uploads / yyyy-MM (sheet timezone) var month = Utilities.formatDate(new Date(), SpreadsheetApp.getActive().getSpreadsheetTimeZone(), 'yyyy-MM'); var root = DriveApp.getFolderById(PropertiesService.getScriptProperties().getProperty('uploadsRootId')); var folder = getOrCreateFolderPath(root, client + '/Uploads/' + month); // One answer cell can hold several URLs var urls = String((answers[UPLOAD_QUESTION] || [''])[0]) .split(',') .map(function (s) { return s.trim(); }) .filter(Boolean); urls.forEach(function (url) { var fileId = extractDriveFileId(url); var res = moveFileToFolder(fileId, folder); console.log('%s → %s (%s)', res.file.getName(), folder.getName(), res.moved ? 'moved' : 'already there'); }); }

The three helpers are in the catalog:

Map messy answers to real folders

If “Client” is free text, don’t trust it for folder names. Keep a small Clients tab (Answer | Folder name) and look it up:

function canonicalClient_(answer) { var rows = SpreadsheetApp.getActive().getSheetByName('Clients').getDataRange().getValues(); var key = String(answer).trim().toLowerCase(); for (var i = 1; i < rows.length; i++) { if (String(rows[i][0]).trim().toLowerCase() === key) return rows[i][1]; } throw new Error('Unknown client "' + answer + '" — add it to the Clients tab'); }

The throw is on purpose. With a failure email in place, you hear about a new client the same day, and the file stays in the upload folder until you fix the mapping and re-run.

Re-running is safe

Because moveFileToFolder is idempotent, a small backfill can replay old responses without making duplicates:

function backfillUploads() { var sheet = SpreadsheetApp.getActive().getSheetByName('Form Responses 1'); var values = sheet.getDataRange().getValues(); var headers = values.shift(); values.forEach(function (r) { var namedValues = {}; headers.forEach(function (h, i) { namedValues[h] = [String(r[i])]; }); try { routeUploads({ namedValues: namedValues }); } catch (err) { console.warn('Skipped row: ' + err.message); } }); }

Watch the 6-minute limit on big backfills. Run it in slices if you have hundreds of responses.

Failure modes worth naming

  • Trigger owner can’t edit the target folder: the move fails. Share the client root with the trigger owner.
  • Shared drive targets: moving into a shared drive needs content-manager access there, and ownership rules differ. Test with one file first.
  • Optional upload left blank: the URL list is empty; the loop simply does nothing. Fine.
  • Renamed question: UPLOAD_QUESTION no longer matches, and nothing moves. Log a warning when the key is missing from e.namedValues.
  • Sharing surprises: files pick up access from the new folder. Don’t file client uploads into a folder the whole team, or the client, can see unless that’s the goal.

Soft Co-Pilot note

NitroGAS keeps moveFileToFolder, getOrCreateFolderPath, and extractDriveFileId a search away in the editor. Describe your layout (“Client / Uploads / month, client from the dropdown”) and Co-Pilot can draft routeUploads for you. You still install the trigger and test with a real submission. Free core; Co-Pilot is a pass you grab when you want the draft.

Closing checklist

  • Installable on-form-submit trigger (not a simple trigger)
  • Client answer is a dropdown or mapped to canonical names
  • Folder path built with get-or-create; root id in Script Properties
  • Files moved (not copied), idempotently
  • Unknown clients throw; moves are logged

Happy Coding!