# Recognize your data's shape

> The export shapes you're likely to meet, how to tell them apart, and how each one maps to one row per message.

Before you write any code, answer one question: **what does one row of my export hold?** The answer decides the recipe. Open the file (in a text editor for CSV and JSON, not only in Excel) and look at the first few rows.

Every shape ends up the same way: one row per message, with the five required fields from the [conversation data format](/data/conversation-format). The tables below say where each field comes from.

## One row per message

**How to recognize it:** a column with the message text, a column that says who wrote it (author, sender, role, direction), a column with the time, and an id that repeats on every row of the same conversation.

| Field | Comes from |
|---|---|
| `text` | the message or body column |
| `incoming` | the author or role column, mapped to `true` for the customer and `false` for everyone else |
| `sessionId` | the ticket, chat or conversation id |
| `userId` | the customer's id, on the customer's rows and on the replies; if blank, the `sessionId`. Never an email address or name. |
| `dimensionlabs_timestamp` | the sent or created time, in Unix milliseconds |

This is the easiest shape. Use the in-browser [converter](/data/convert-a-file), or the [toolkit](/transform/toolkit) with `"shape": "messages"`.

## One row per conversation, the transcript in one cell

**How to recognize it:** one row per chat, ticket or call, and a long cell that holds every message, one per line, each starting with a role label such as `Agent:`, `Customer:`, `BOT:` or `USER:`. Conversation-level columns sit beside it: a summary, a rating, a category.

**How it maps:** split the cell into lines, read the label at the start of each line to set `incoming`, and give each turn its own row in the same session. Lines without a label belong to the turn above them. The conversation-level columns become extra columns on every turn.

Recipe: [Split a transcript into turns](/transform/recipe-split-transcript).

## Call transcripts with speaker names

**How to recognize it:** like the shape above, but each line starts with a person's name (`Sam Rivera: ...`), sometimes after a time offset (`[00:01:15]`). The export may also have a participants column.

**How it maps:** a name doesn't tell you the direction, so you need the list of people who speak for your company. Everyone else is the customer. Time offsets, when present, give each turn its real time.

Recipe: [Turn call transcripts into turns](/transform/recipe-call-transcripts).

## One row per survey respondent

**How to recognize it:** one row per response, one column per question, often with a second header row that repeats the question wording. Scores (NPS, ratings, Likert answers such as `5 - Strongly agree`) sit next to open-ended text answers.

**How it maps:** each respondent becomes one session. Each open-ended question becomes an outgoing row (`incoming: false`) followed by the answer as an incoming row. Scores stay as extra columns. Blank answers are skipped. See [Reviews, surveys and tickets](/data/reviews-surveys-tickets#surveys) for why the questions go in too.

Recipe: [Reshape a wide survey](/transform/recipe-wide-survey). The [converter](/data/convert-a-file) handles most single-file cases, including a row of question wording under the column names.

## One row per respondent per question

**How to recognize it:** a "long" survey export with columns like `response_id`, `question_code` and `value`, so one respondent spans many rows.

**How it maps:** group the rows by respondent, put the questions in the order they were asked, and write a question row and an answer row for each. Scores (rows whose value is a number) become extra columns instead of messages.

Use the same recipe: [Reshape a wide survey](/transform/recipe-wide-survey) shows the question-and-answer pattern; group by the respondent id first.

## One row per question and answer pair

**How to recognize it:** a bot or search log with a request column and a response column on the same row.

**How it maps:** two rows per input row: the request as `incoming: true`, the response as `incoming: false`, one second later, in the same session. Both rows carry the end user's id.

This script does it for a CSV with the example columns `session_id`, `user_id`, `asked_at_ms`, `request` and `response`; rename them to yours. It skips an empty half and keeps messages in a session at least 1,000 ms apart.

```python title="pairs_to_rows.py"
# One row per request and response pair to one row per message.
# Usage: python3 pairs_to_rows.py pairs.csv messages.csv



src, dst = sys.argv[1], sys.argv[2]
last = {}  # sessionId -> last timestamp written, to keep messages 1,000 ms apart
with open(src, newline='', encoding='utf-8') as f, open(dst, 'w', newline='', encoding='utf-8') as out:
    w = csv.writer(out, lineterminator='\n')
    w.writerow(['sessionId', 'userId', 'incoming', 'text', 'dimensionlabs_timestamp'])
    for row in csv.DictReader(f):
        sid = row['session_id'].strip()
        uid = row['user_id'].strip() or sid  # the end user's id on both rows
        t = int(row['asked_at_ms'])
        for incoming, text in (('true', row['request']), ('false', row['response'])):
            text = ' '.join(text.split())
            if not text:
                continue
            t = max(t, last.get(sid, t - 1000) + 1000)
            w.writerow([sid, uid, incoming, text, t])
            last[sid] = t
```

```csv title="pairs.csv"
session_id,user_id,asked_at_ms,request,response
s-1,u-7,1767460320000,Is the Pearl store open today?,"Yes, until 6 PM."
s-1,u-7,1767460320500,Do you have oat milk?,We do.
s-2,,1767460400000,Hours on Sunday?,
```

```csv title="messages.csv"
sessionId,userId,incoming,text,dimensionlabs_timestamp
s-1,u-7,true,Is the Pearl store open today?,1767460320000
s-1,u-7,false,"Yes, until 6 PM.",1767460321000
s-1,u-7,true,Do you have oat milk?,1767460322000
s-1,u-7,false,We do.,1767460323000
s-2,s-2,true,Hours on Sunday?,1767460400000
```

## Reviews, with or without owner replies

**How to recognize it:** one row per review, with a rating, a location, a date and the review text. Some exports add the business's reply and its date.

**How it maps:** each review is its own session, with the review id as `sessionId` and `userId`. The review is `incoming: true`. A reply, if present, is a second row in the same session with `incoming: false` and the reply's own time. The rating stays as an extra column. Reviews with no text are dropped.

Recipe: [Excel and Excel dates](/transform/recipe-excel) uses a review export as its example. For reviews without replies, the [converter](/data/convert-a-file) does it.

## Nested JSON exports

**How to recognize it:** a `.json` file (or a folder of them) holding an array of objects, with nested objects such as `sender: { id, email }`, arrays of messages inside conversations, HTML bodies, and often a second file with conversation-level fields (status, satisfaction, tags).

**How it maps:** walk the nesting down to the message level, pick each field by its path (`sender.id`), skip internal notes, convert HTML to text, and join the conversation-level fields onto every message by the conversation id.

Recipe: [Nested JSON to rows](/transform/recipe-nested-json). For JSONL with one message per line, the [toolkit](/transform/toolkit) reads dot paths directly.

## Excel workbooks

**How to recognize it:** an `.xlsx` file, often with several sheets, a title row above the header, dates shown as dates but stored as numbers, and long ids shown as `4.4E+12`.

**How it maps:** read the workbook directly with a library (never resave it as CSV from Excel, which can drop digits from long numbers), pick the sheet and the header row, convert Excel date numbers to Unix milliseconds in the right time zone, and write ids as whole numbers.

Recipe: [Excel and Excel dates](/transform/recipe-excel).

## Parquet files and warehouse tables

**How to recognize it:** a `.parquet` file or a table in Snowflake, BigQuery, Databricks or Redshift, often with upper-case column names and real timestamp types.

**How it maps:** select and rename columns in SQL, so the warehouse does the work. For a scheduled feed from a warehouse, see [Data warehouses](/integrations/warehouses). For a one-off Parquet file, DuckDB writes the CSV in one statement:

```python title="parquet_to_csv.py"
# Parquet to an upload-ready CSV with DuckDB. Usage: python3 parquet_to_csv.py messages.parquet messages.csv
# pip install duckdb



src, dst = sys.argv[1], sys.argv[2]
duckdb.sql(f"""
COPY (
  SELECT
    CONV_ID                                   AS sessionId,
    COALESCE(NULLIF(USER_ID, ''), CONV_ID)    AS userId,
    CASE WHEN IS_CUSTOMER THEN 'true' ELSE 'false' END AS incoming,
    regexp_replace(TEXT, '\\s+', ' ', 'g')    AS text,
    epoch_ms(SENT_AT)                         AS dimensionlabs_timestamp,
    CONV_ID                                   AS conversationId
  FROM read_parquet('{src}')
  WHERE trim(coalesce(TEXT, '')) <> ''
  ORDER BY sessionId, dimensionlabs_timestamp
) TO '{dst}' (HEADER, DELIMITER ',')
""")
```

The column names above (`CONV_ID`, `USER_ID`, `IS_CUSTOMER`, `TEXT`, `SENT_AT`) are examples; replace them with yours. `epoch_ms` reads a `TIMESTAMP` with no time zone as UTC. If `SENT_AT` holds local clock times, convert it first, for example `epoch_ms(timezone('America/Chicago', SENT_AT))` for times recorded in Chicago; a `TIMESTAMPTZ` column is already exact. If `USER_ID` holds the agent's id on agent rows, take the customer's id from the session instead.

## Business records: orders, accounts, opportunities

**How to recognize it:** rows that describe things customers did or own, not things they wrote: orders and line items, subscriptions, CRM accounts and opportunities, store lists.

**How it maps:** these are not conversation data. Either attach a few fields to conversations as extra columns, or upload the file as its own table under **Structured data** and let the agent join on a shared id. See [Join business data](/data/join-business-data) and [Business data format](/data/business-data-format).

Recipe: [Attach business fields](/transform/recipe-business-fields).

## Several exports of the same data

**How to recognize it:** monthly files that overlap, a chat export and an email export for the same customers, or a ticket export plus a separate comments export.

Recipe: [Combine several exports](/transform/recipe-combine-exports).
