Reshape a wide survey
Recipe: turn a survey export with one row per respondent into question and answer rows, with scores kept as columns.
When you need it
Section titled “When you need it”Your survey tool exports one row per respondent and one column per question. Open-ended answers sit next to scores, and many tools add a second header row with the question wording. For most files the in-browser converter does this, and it can take the question wording from the row under the column names. Use this recipe when you need more control: incomplete responses to drop, Likert labels to turn into numbers, odd date formats.
Example input, survey-export.csv. Row 2 holds the question wording. R_102’s first answer spans two lines and its second is blank; R_103 didn’t finish. R_101’s second answer contains a non-breaking space, as text pasted from a phone often does.
ResponseId,StartDate,Finished,Q1_nps,Q2_easy,Q3_why,Q4_improveResponse ID,Start Date,Finished,"How likely are you to recommend Lumen Coffee to a friend? (0-10)",The app makes ordering easy,Why did you give that score?,What one thing should we improve?R_101,3/1/26 8:15,True,9,5 - Strongly agree,The baristas know my order.,Faster pickup at rush hourR_102,3/1/26 9:40,True,3,2 - Disagree,"Two cold drinks in a row.Not great.",R_103,3/2/26 18:05,False,7,,,Output
Section titled “Output”sessionId,userId,incoming,text,dimensionlabs_timestamp,conversationId,question,Q1_nps,Q2_easyR_101,R_101,false,Why did you give that score?,1772370900000,R_101,Q3_why,9,5R_101,R_101,true,The baristas know my order.,1772370901000,R_101,Q3_why,9,5R_101,R_101,false,What one thing should we improve?,1772370902000,R_101,Q4_improve,9,5R_101,R_101,true,Faster pickup at rush hour,1772370903000,R_101,Q4_improve,9,5R_102,R_102,false,Why did you give that score?,1772376000000,R_102,Q3_why,3,2R_102,R_102,true,Two cold drinks in a row. Not great.,1772376001000,R_102,Q3_why,3,2- Each respondent is one session. The response id is the
sessionId,userIdandconversationId. - Questions go in as outgoing rows. Each open-ended question is
incoming: false, then its answer isincoming: trueone second later. The model reads only text, so a short answer like “Faster pickup” needs its question next to it. See Reviews, surveys and tickets. - Take the question wording from the export. The second header row is the text respondents saw, so the script uses it rather than a hand-typed copy. If your export has no wording row, type each question exactly as respondents saw it.
- Scores stay as extra columns. NPS and ratings are numbers, not text to enrich.
5 - Strongly agreebecomes5, so the agent can average it. - Skip blank answers; don’t fill them. No
[No Response]rows: placeholder text reads like a real answer to the model (conversation format). - Drop incomplete responses if your survey tool marks them (
FinishedisFalsehere), unless partial answers matter to you. - Clean the text. Replace non-breaking spaces and line breaks with plain spaces.
- State the time zone. The export’s
3/1/26 8:15is Eastern time. The Node script uses a fixed offset (-05:00), which is right only while every response falls in standard time; for dates across a daylight saving change, use a named zone as in Fix timestamps, or the Python version below.
Script
Section titled “Script”Node 18 or later, with npm install papaparse.
// Reshape a wide survey export (one row per respondent) into one row per question and answer.// Usage: node survey-to-rows.mjs survey-export.csv survey-rows.csvimport fs from 'node:fs';import Papa from 'papaparse';
const [input, output] = process.argv.slice(2);const OPEN_ENDED = ['Q3_why', 'Q4_improve']; // these become messagesconst SCORES = ['Q1_nps', 'Q2_easy']; // these stay as extra columnsconst TIME_ZONE_OFFSET = '-05:00'; // the survey tool exported Eastern Standard Time
const [header, questionText, ...answers] = Papa.parse(fs.readFileSync(input, 'utf8'), { skipEmptyLines: 'greedy' }).data;const col = (name) => header.indexOf(name);const clean = (s) => (s ?? '').replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim();const score = (s) => { const m = clean(s).match(/^(\d+)/); return m ? m[1] : ''; }; // "5 - Strongly agree" -> 5
// "3/1/26 8:15" -> Unix msfunction toMillis(s) { const [, mo, d, y, h, mi] = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2,4}) (\d{1,2}):(\d{2})$/); const year = y.length === 2 ? `20${y}` : y; return Date.parse(`${year}-${mo.padStart(2, '0')}-${d.padStart(2, '0')}T${h.padStart(2, '0')}:${mi}:00${TIME_ZONE_OFFSET}`);}
const rows = [];let blank = 0;for (const r of answers) { const id = r[col('ResponseId')]; if (r[col('Finished')] !== 'True') continue; // drop incomplete responses const extra = Object.fromEntries(SCORES.map((c) => [c, score(r[col(c)])])); let t = toMillis(r[col('StartDate')]); for (const c of OPEN_ENDED) { const answer = clean(r[col(c)]); if (!answer) { blank++; continue; } // no placeholder rows const base = { sessionId: id, userId: id, conversationId: id, question: c, ...extra }; rows.push({ ...base, incoming: 'false', text: clean(questionText[col(c)]), dimensionlabs_timestamp: t }); rows.push({ ...base, incoming: 'true', text: answer, dimensionlabs_timestamp: t + 1000 }); t += 2000; }}
const fields = ['sessionId', 'userId', 'incoming', 'text', 'dimensionlabs_timestamp', 'conversationId', 'question', ...SCORES];fs.writeFileSync(output, Papa.unparse({ fields, data: rows.map((r) => fields.map((f) => r[f])) }, { newline: '\n' }) + '\n');console.log(`${answers.length} responses -> ${rows.length} rows (${blank} blank ${blank === 1 ? 'answer' : 'answers'} skipped)`);node survey-to-rows.mjs survey-export.csv survey-rows.csv# 3 responses -> 6 rows (1 blank answer skipped)In Python, with pandas
Section titled “In Python, with pandas”melt is the pandas name for wide-to-long. This version takes a named time zone, so daylight saving time is handled. It writes the same file.
# Reshape a wide survey export into one row per question and answer, with pandas.# Usage: python3 survey_to_rows.py survey-export.csv survey-rows.csvimport sysimport pandas as pd
OPEN_ENDED = ["Q3_why", "Q4_improve"]SCORES = ["Q1_nps", "Q2_easy"]TIME_ZONE = "America/New_York"
src, dst = sys.argv[1], sys.argv[2]raw = pd.read_csv(src, dtype=str, keep_default_na=False)question_text = raw.iloc[0] # the second header row holds the question wordingdf = raw.iloc[1:]df = df[df["Finished"] == "True"].copy() # drop incomplete responses
clean = lambda s: " ".join(str(s).replace("\u00a0", " ").split())for c in SCORES: df[c] = df[c].str.extract(r"^(\d+)", expand=False).fillna("") # "5 - Strongly agree" -> 5start = pd.to_datetime(df["StartDate"], format="%m/%d/%y %H:%M").dt.tz_localize(TIME_ZONE)df["start_ms"] = (start - pd.Timestamp("1970-01-01", tz="UTC")) // pd.Timedelta(milliseconds=1)
long = df.melt(id_vars=["ResponseId", "start_ms", *SCORES], value_vars=OPEN_ENDED, var_name="question", value_name="answer")long["answer"] = long["answer"].map(clean)long = long[long["answer"] != ""] # no placeholder rows for blank answerslong["order"] = long["question"].map(OPEN_ENDED.index)long = long.sort_values(["ResponseId", "order"])long["n"] = long.groupby("ResponseId").cumcount()
rows = []for r in long.itertuples(): base = {"sessionId": r.ResponseId, "userId": r.ResponseId, "conversationId": r.ResponseId, "question": r.question, **{c: getattr(r, c) for c in SCORES}} t = r.start_ms + r.n * 2000 rows.append({**base, "incoming": "false", "text": clean(question_text[r.question]), "dimensionlabs_timestamp": t}) rows.append({**base, "incoming": "true", "text": r.answer, "dimensionlabs_timestamp": t + 1000})
cols = ["sessionId", "userId", "incoming", "text", "dimensionlabs_timestamp", "conversationId", "question", *SCORES]pd.DataFrame(rows)[cols].to_csv(dst, index=False, lineterminator="\n")print(f"{len(df)} complete responses -> {len(rows)} rows")pip install pandaspython3 survey_to_rows.py survey-export.csv survey-rows.csvWith the toolkit
Section titled “With the toolkit”The toolkit’s survey shape does the reshaping from a mapping file. questionsFromRow reads the question wording from row 2, and skipRowsAfterHeader keeps that row from being read as a respondent. It keeps Likert labels as they are (5 - Strongly agree).
{ "input": { "skipRowsAfterHeader": 1 }, "shape": "survey", "columns": { "sessionId": "ResponseId", "timestamp": "StartDate" }, "timestamp": { "timeZone": "America/New_York" }, "skip": [{ "column": "Finished", "values": ["False"] }], "survey": { "questionsFromRow": 2, "questions": ["Q3_why", "Q4_improve"] }, "metadata": { "keep": ["Q1_nps", "Q2_easy"] }}node to-dimensionlabs.mjs --config survey.config.json --input survey-export.csv --out out/surveyVariations
Section titled “Variations”- Qualtrics adds two rows under the column names: the question wording, then a row of
{"ImportId":...}values. Skip both ("skipRowsAfterHeader": 2in the toolkit, or drop the first two data rows in a script) and read the wording from row 2. - A long export (one row per respondent per question): group rows by respondent first, then write the pairs in the order the questions were asked.
- Questions that depend on an earlier answer, such as “You gave us a 3. What could we do better?”: write the question as the respondent saw it, with the score filled in.
- Section headings (“How much do you agree with the following?”): leave them out unless the answers don’t make sense without them.