Tutorials

How to Export Gravity Forms File Uploads

Illustration for exporting Gravity Forms file uploads

You built a Gravity Forms form with a file upload field. Applicants sent résumés, customers sent damage photos, contractors sent signed PDFs. Now you need all of it off the site — for an audit, a migration, a handover. So you run the built-in export, open the CSV, and find a column full of https://example.com/wp-content/uploads/gravity_forms/3-a1b2c3.../2026/07/resume.pdf. No files. Just links.

That is not a bug. Gravity Forms’ entry export is a CSV export of entry data, and a file upload field’s stored value is a URL string. The bytes stay on disk. This post covers where those bytes live, three practical ways to pull them down in bulk, how to keep each file matched to its entry (the part everyone skips and later regrets), what happens to uploads when entries are deleted, and the retention question you should be asking if any of those files are ID documents.

Why the built-in export gives you URLs, not files

Under the hood a Gravity Forms entry is a row of field values keyed by field ID. For a text field the value is the text. For a file upload field the value is a URL (or, for multi-file fields, a JSON-encoded array of URLs). The CSV exporter serialises whatever is in that column, so you get the string — and a CSV is flat text with no container for binary attachments, so there is genuinely nowhere for a PDF to go. If you have not done the entry half yet, our walkthrough of exporting Gravity Forms entries covers the filters and field selection that make the CSV usable in the first place.

Where Gravity Forms actually stores uploads

Uploads do not go into the Media Library. They land in their own tree:

wp-content/uploads/gravity_forms/{formID}-{hash}/{year}/{month}/filename.ext
  • The hash is per-form and per-site. It is derived from the form ID and a site-specific salt, which is why the folder name differs between staging and production. Never hard-code it in a reusable script.
  • Year/month subfolders follow the submission date, so a long-running form spreads across many directories.
  • Filename collisions get suffixed. Two people uploading resume.pdf produce resume.pdf and resume1.pdf. This is the biggest reason a raw folder dump is unreadable later.

The security bit: those folders are often browsable

Gravity Forms drops an .htaccess file into its upload root to deny direct access, which works on Apache. On nginx — most modern managed hosting — .htaccess is ignored entirely. The hashed folder is then protected only by the obscurity of its name, and those URLs are unguessable but not private: anyone who has ever received one, in a notification email or a forwarded CSV, can fetch it forever.

Before you export anything, test it. Open one upload URL in a private browser window while logged out. If the file loads, every file in that form is effectively public to anyone holding a link — and if the bare directory also loads a listing, worse.

The fixes are unglamorous: add an nginx location block denying direct access to /wp-content/uploads/gravity_forms/, or move the upload root outside the web root via the gform_upload_path filter.

Method A: FTP/SFTP — the honest default

For a one-off handover this is the right answer, and people talk themselves out of it because it feels too simple. Connect over SFTP, go to wp-content/uploads/gravity_forms/, find your form’s folder by the leading form ID, download it. With SSH it is one command, and far more reliable than a GUI client on thousands of small files:

ssh user@host "cd wp-content/uploads && zip -r /tmp/gf-form3.zip gravity_forms/3-*"
scp user@host:/tmp/gf-form3.zip .

Pros: complete, fast, no plugin, no server load. Cons: you get every file the form ever received — including spam and deleted entries — and the folder structure tells you nothing about who submitted what.

Method B: extract the URL column and fetch with wget or curl

Use this when you want only the files belonging to a filtered set of entries — last quarter’s applications, one campaign, one status.

Export entries to CSV including the upload field, then pull that column out. If the upload URL is column 7:

cut -d, -f7 entries.csv | tail -n +2 | tr -d '"' > urls.txt
wget -i urls.txt -P downloads/ --content-disposition

Two caveats. First, if you correctly locked those folders down, an unauthenticated wget now gets 403s — a good sign, and a cue to use Method A. Second, multi-file fields store a JSON array in one cell, so a plain cut gives you a messy blob per row. The pragmatic fix ignores column position entirely and harvests every URL in the file:

grep -oE 'https?://[^",]+' entries.csv > urls.txt

Method C: add-ons that ZIP files with the export

Several third-party add-ons (GFExport and similar entry-export extensions, plus “download all uploads” utilities) produce a ZIP of attachments alongside the CSV, and some rename files by entry ID as they go. If you do this monthly rather than once, that is worth a licence. Judge them on three things: whether the ZIP is built on the server (memory limits and timeouts on a 4 GB folder are real), whether it respects your entry filters or just grabs the directory, and whether the naming scheme is configurable. An add-on that hands you the same opaque filenames as FTP has not solved the actual problem.

Keeping filenames matched to the submitting entry

This is the difference between a usable export and a folder of 900 files called image.jpg through image899.jpg.

Build the mapping before you download, while you still have the CSV pairing entry ID with URL. Include the entry ID and a human identifier in the export, then generate the download from it. Given a CSV of entry_id,email,url:

while IFS=, read -r id email url; do
  ext="${url##*.}"
  curl -sL "$url" -o "downloads/${id}-${email}.${ext}"
done < mapping.csv

Now every file is named for its entry and the CSV stays the index. Without a terminal, hand the CSV over alongside the ZIP — a reader with the mapping can reconstruct it, a reader without it never can. The same discipline applies to the entries themselves: our notes on storing and exporting WordPress form entries go into why a database table plus an untracked upload folder is a fragile pair.

What happens to uploads when you delete entries

Short version: historically, not what you would hope. Trashing an entry certainly does not remove the file, and permanent deletion has not always done so either. Recent versions clean up better, but behaviour varies by version and by whether another plugin intercepted the delete.

  • Your upload folder is a superset of your entries. File count rarely matches entry count. Do not read a mismatch as a failed export.
  • “Deleted” does not mean unreachable. An orphaned file at a known URL is still fetchable, so deleting an entry may not have honoured an erasure request.
  • Verify, don’t assume. Delete a test entry, then check the folder and try the URL yourself.

GDPR and retention when the uploads are ID documents

Everything above changes character when the files are passports, licences, medical notes or bank statements.

An export is a copy, and copies multiply obligations. The ZIP on your laptop, the one in a client’s inbox and the one in Drive are all processing of personal data. Export with a defined destination and deletion date, not “just in case”.

Retention has to reach the files. A policy that says “we delete applications after 12 months”, implemented as an entry-deletion cron, leaves documents on disk if deletion does not cascade. Test the cascade.

Access requests include attachments — much easier to fulfil if filenames encode the entry ID. Our guide to GDPR and WordPress forms covers consent records, retention and erasure in more depth.

Full disclosure: our own free plugin, EntryVault, captures entries from 11 form builders including Gravity Forms and exports them to CSV, giving you an independent record of who submitted what even if the form plugin is later removed. It exports entry data, so the file-fetching steps above still apply — but a stable, form-plugin-independent entry index is what lets the filename mapping survive a migration.

A workable order of operations

  1. Check whether your upload folder is publicly readable. Fix it before exporting.
  2. Export entries to CSV with entry ID, a human identifier, and the upload field.
  3. Decide: whole folder (Method A) or filtered set (Method B).
  4. Download, renaming by entry ID as you go.
  5. Spot-check five files against five entries.
  6. Record where the copy lives and when it gets deleted.

Steps 1 and 6 get skipped most, and are the two that turn a routine export into an incident.

Frequently asked questions

Can Gravity Forms export entries and their file uploads together in one download?

Not with the built-in exporter. The core entry export produces a CSV, and because CSV is a plain text format it can only contain the URL of an uploaded file, not the file itself. To get both together you either download the upload folder separately over FTP/SFTP, fetch the URLs from the CSV with wget or curl, or install a third-party export add-on that builds a ZIP of attachments alongside the CSV.

Where does Gravity Forms store uploaded files on the server?

In wp-content/uploads/gravity_forms/, inside a folder named for the form ID plus a site-specific hash, then split into year and month subfolders by submission date. For example, form 3 might store files under gravity_forms/3-9f8c1d4e.../2026/07/. The hash differs between sites, so the exact path on your staging site will not match production.

Are Gravity Forms upload files deleted when I delete the entry?

Not reliably. Trashing an entry does not remove the file, and depending on your Gravity Forms version and how the entry was deleted, a permanent delete may also leave the file on disk. Test it on your own install: delete a test entry, then check the upload folder and try loading the file URL while logged out. If the file is still reachable, your retention policy needs to delete files explicitly, not just entries.