Export Typeform to Google Sheets: The Native Integration, a CSV Import, or Apps Script
To export Typeform to Google Sheets, the fastest option is Typeform’s own Google Sheets integration: open the form’s Connect panel, connect Google Sheets, and every new submission lands as a row in a spreadsheet you choose. If you only need a one-time copy of past answers, download the responses as CSV and import the file into Sheets. If you need full history beyond the integration’s backfill, hidden fields in a fixed layout, or control over when data refreshes, a short Apps Script that calls the Typeform Responses API does the job. This guide covers all three and the limits that decide between them.
If your sheet is only a stop on the way to a dashboard, the Typeform to Power BI and Typeform to Qlik guides pick up from here. For raw data without a spreadsheet, see Export Typeform to JSON.
Route 1: The built-in Google Sheets integration
This is the route most people want, and it needs no code.
- Open the form in Typeform and go to Connect.
- Find Google Sheets, click connect, and sign in with the Google account that should own the spreadsheet.
- Choose to create a new spreadsheet or send responses to an existing one.
Typeform’s setup guide describes the layout: one row per submission, one column per question, plus Submitted At and Token, the unique response ID. From then on, the sheet fills itself.
Plan for these limits before the sheet becomes something people rely on:
- Backfill is partial. When you connect a form that already has responses, Typeform only copies existing results for forms with up to 1,000 of them, and partial responses are not backfilled. Older history needs a CSV import (Route 2) on top.
- There is a size ceiling. Typeform says the integration is deactivated once the spreadsheet reaches 2 million cells across all tabs. A form with 40 columns hits that at 50,000 responses, sooner if other tabs share the file. Start a new spreadsheet per year or per campaign if you are anywhere near that.
- Partial responses can duplicate rows. If you send partial responses to integrations, one respondent can produce one row at the partial submit point and another when they finish. Deduplicate on
Token. - Do not reshape the response tab. Add formulas, pivots, and charts on a separate tab that reads from the response tab, so the integration keeps writing to the columns it expects.
A clean way to deduplicate on a second tab, keeping the last row per token:
=SORTN(SORT(Responses!A2:Z, ROW(Responses!A2:A), FALSE), 9^9, 2, MATCH("Token", Responses!1:1, 0), TRUE)
Adjust the range to your columns. SORTN with mode 2 drops later duplicates of the token column, and the descending sort by row number makes the newest row the one that survives.
Route 2: Import a CSV export
For a single analysis, or to fill in the history the integration did not backfill:
- In Typeform, go to Results, then Responses, select the rows, and export as CSV. Typeform’s export help page notes that large exports arrive by email.
- In Google Sheets, use File, then Import, upload the CSV, and choose whether to insert a new sheet or append to the current one.
The export has its own shape. A multi-select question becomes one column per answer choice, Yes/No answers come out as 1 and 0, and Start Date and Submit Date are in UTC even though Typeform’s interface shows local time. If you append it under rows written by the integration, check that the columns line up first, because the two layouts are not guaranteed to match.
Route 3: Pull the Responses API with Apps Script
Use this when you want the complete history in one place, hidden fields and scores in stable columns, or a refresh on your own schedule. The script below runs inside the spreadsheet, so nothing else needs hosting.
Before you start
- Create a personal access token in Typeform under Account, then Personal tokens, with read scopes for responses and forms. Typeform’s token documentation warns that the token acts on your account, so keep its scopes narrow.
- Use the right regional domain. Typeform’s data center page explains that querying the wrong one returns empty results. US accounts use
api.typeform.com; EU accounts useapi.eu.typeform.comorapi.typeform.eu, depending on when they were onboarded. - Copy the form ID from the form’s URL, for example
u6nXL7.
In the spreadsheet, open Extensions, then Apps Script. Store the token as a script property rather than in the code: in the editor’s Project Settings, add a property named TYPEFORM_TOKEN.
The script
const FORM_ID = 'u6nXL7';
const API = 'https://api.typeform.com';
const SHEET_NAME = 'API responses';
function syncTypeform() {
const token = PropertiesService.getScriptProperties().getProperty('TYPEFORM_TOKEN');
const headers = { Authorization: 'Bearer ' + token };
// Question titles, keyed by ref, in form order.
const form = JSON.parse(UrlFetchApp.fetch(`${API}/forms/${FORM_ID}`, { headers }).getContentText());
const fields = [];
(form.fields || []).forEach(f => {
if (f.type === 'group') (f.properties.fields || []).forEach(g => fields.push(g));
else fields.push(f);
});
const hiddenKeys = form.hidden || [];
// Page backwards through responses with the "before" cursor.
const items = [];
let before = '';
while (true) {
const url = `${API}/forms/${FORM_ID}/responses?page_size=1000` + (before ? `&before=${before}` : '');
const page = JSON.parse(UrlFetchApp.fetch(url, { headers }).getContentText());
if (!page.items || page.items.length === 0) break;
items.push(...page.items);
before = page.items[page.items.length - 1].token;
}
const header = ['token', 'submitted_at_utc', ...fields.map(f => f.title), ...hiddenKeys];
const rows = items.map(item => {
const byRef = {};
(item.answers || []).forEach(a => { byRef[a.field.ref] = answerValue(a); });
return [
item.token,
item.submitted_at,
...fields.map(f => byRef[f.ref] ?? ''),
...hiddenKeys.map(k => (item.hidden || {})[k] ?? ''),
];
});
const ss = SpreadsheetApp.getActive();
const sheet = ss.getSheetByName(SHEET_NAME) || ss.insertSheet(SHEET_NAME);
sheet.clearContents();
sheet.getRange(1, 1, 1, header.length).setValues([header]);
if (rows.length) sheet.getRange(2, 1, rows.length, header.length).setValues(rows);
}
function answerValue(a) {
switch (a.type) {
case 'choice': return a.choice.label || a.choice.other || '';
case 'choices': return (a.choices.labels || []).concat(a.choices.other ? [a.choices.other] : []).join(', ');
case 'payment': return a.payment.amount;
default: return a[a.type] ?? '';
}
}
How it works, and why:
- Paging follows Typeform’s cursor. The Retrieve responses endpoint returns up to 1,000 items per page, newest first. You get the previous page by passing the last item’s
tokenasbefore, and the loop stops on an empty page. - Answers are matched by
ref, not position. Responses only include questions the respondent answered, so lining them up by index would shift columns. Mapping by each field’srefkeeps every answer under its own question. - Multi-select stays in one cell. Unlike the CSV export, the script joins
choiceslabels with commas, which is usually easier to filter in Sheets. - Timestamps stay in UTC, as the API returns them. Convert in a formula on another tab if you need local time.
- Only completed responses come back unless you add
response_typeto the query, and Typeform notes the very latest submissions may take a short while to appear.
Run syncTypeform once from the editor to grant permissions, then add a time-driven trigger under Triggers (hourly or daily is plenty). Apps Script has execution time quotas, so for forms with tens of thousands of responses, switch to an incremental sync: read the newest submitted_at in the sheet, pass it as since, and append new rows instead of rewriting the tab.
Responses that never reached Typeform
Paper copies filled in at an event, signed PDFs sent back by email, or a photo of a sign-up sheet never pass through Typeform. If they belong in the same spreadsheet, extract them into the same columns. With ocrskill.com you list the fields you want and get JSON back:
curl "https://api.ocrskill.com/ocr.json?fields=last_name,first_name,email,company_name?" \
-H "Authorization: Bearer sk-your-key-here" \
-F "file=@event-signup-scan.jpg"
Required fields return a 400 when the page cannot support them, so a blurry scan fails loudly instead of becoming a half-empty row; optional keys end in ?. Append the results to the sheet with a source column set to paper. The form data extraction API post goes deeper on form OCR.
Which route to pick
| Situation | Route |
|---|---|
| New responses should appear on their own, no code | Built-in Google Sheets integration |
| One-off analysis or missing history | CSV export, then File and Import |
| Full history, hidden fields, your own refresh schedule | Apps Script with the Responses API |
| Paper, PDF, or photo forms | OCR to JSON, appended with a source column |
Conclusion
The native integration covers most teams, and its weak points are predictable: it backfills only forms with up to 1,000 responses, it stops at 2 million cells, and partial responses can write a second row per person. A CSV import fills the history gap, and a dedup formula on a separate tab handles the duplicates. Once a form outgrows those limits, or you need hidden fields in fixed columns, the Apps Script route replaces the integration with a sheet you fully control, as long as you query the right regional domain, page with the before token, and match answers by ref.
