Convert Salesforce JSON to CSV
Every Salesforce SOQL query result wraps records in an envelope and attaches an attributes object to each row. Stripping those and flattening relationship fields is all it takes to get clean CSV.
Salesforce REST API query results follow a consistent pattern: a totalSize and done flag at the top level, a records array where each record has an attributes object (containing type and url), and any relationship fields as nested objects. This structure is the same whether you're querying Contacts, Leads, Opportunities, or custom objects. Once you know how to handle it, any Salesforce export follows the same logic.
Want to skip the code? Paste your JSON directly into the converter — it handles nested objects, arrays, and large files automatically.
Open JSON to CSV ConverterWhat the API returns
Salesforce REST API SOQL query result — contacts with related Account
{
"totalSize": 2,
"done": true,
"records": [
{
"attributes": {
"type": "Contact",
"url": "/services/data/v59.0/sobjects/Contact/003Dn000004ABCDE"
},
"Id": "003Dn000004ABCDE",
"FirstName": "Alice",
"LastName": "Chen",
"Email": "alice@example.com",
"Title": "VP Engineering",
"Department": "Engineering",
"Phone": "512-555-0100",
"Account": {
"attributes": {
"type": "Account",
"url": "/services/data/v59.0/sobjects/Account/001Dn000003XYZAB"
},
"Name": "Acme Corp",
"Industry": "Technology"
},
"CreatedDate": "2025-01-15T09:30:00.000+0000",
"LastModifiedDate": "2025-03-20T14:22:00.000+0000"
}
]
}Field mapping: JSON path → CSV column
| JSON path | CSV column | Notes |
|---|---|---|
| Id | salesforce_id | 18-char Salesforce record ID |
| FirstName | first_name | |
| LastName | last_name | |
| Title | title | |
| Department | department | |
| Phone | phone | |
| Account.Name | account_name | Relationship field — nested object |
| Account.Industry | account_industry | |
| CreatedDate | created_date | ISO 8601 with timezone |
| LastModifiedDate | last_modified | |
| attributes | (drop) | Always remove from output |
Drop the 'attributes' key from every record — it contains internal Salesforce metadata (type name and API URL) that doesn't belong in a CSV export.
Python conversion
Convert Salesforce SOQL query result to CSV
import json
import pandas as pd
with open("sf_contacts.json") as f:
data = json.load(f)
records = data["records"]
def flatten_sf_record(record):
"""Flatten a Salesforce record, dropping attributes and promoting relationship fields."""
out = {}
for key, value in record.items():
if key == "attributes":
continue # drop internal metadata
if isinstance(value, dict) and "attributes" in value:
# Relationship field — flatten it with the parent key as prefix
for sub_key, sub_val in value.items():
if sub_key == "attributes":
continue
out[f"{key}_{sub_key}"] = sub_val
else:
out[key] = value
return out
flat_records = [flatten_sf_record(r) for r in records]
df = pd.DataFrame(flat_records)
# Clean up date columns — Salesforce uses ISO 8601 with timezone offset
for col in df.select_dtypes(include="object").columns:
if df[col].str.match(r"\d{4}-\d{2}-\d{2}T", na=False).any():
df[col] = pd.to_datetime(df[col], errors="coerce", utc=True).dt.strftime("%Y-%m-%d %H:%M")
df.to_csv("contacts.csv", index=False, encoding="utf-8-sig")
print(f"Exported {len(df)} contacts")Query and export using simple_salesforce (handles auth and pagination)
from simple_salesforce import Salesforce
import pandas as pd
sf = Salesforce(
username="user@example.com",
password="your_password",
security_token="your_token",
)
# SOQL query — select the fields you need explicitly
result = sf.query_all("""
SELECT Id, FirstName, LastName, Email, Title, Department,
Phone, Account.Name, Account.Industry,
CreatedDate, LastModifiedDate
FROM Contact
WHERE IsDeleted = false
ORDER BY LastName
""")
print(f"Total records: {result['totalSize']}")
def flatten_sf_record(record):
out = {}
for key, value in record.items():
if key == "attributes":
continue
if isinstance(value, dict) and "attributes" in value:
for sub_key, sub_val in value.items():
if sub_key != "attributes":
out[f"{key}_{sub_key}"] = sub_val
else:
out[key] = value
return out
rows = [flatten_sf_record(r) for r in result["records"]]
df = pd.DataFrame(rows)
df.to_csv("sf_contacts.csv", index=False, encoding="utf-8-sig")
print(f"Exported {len(df)} rows")Export Opportunities with multi-level relationships
from simple_salesforce import Salesforce
import pandas as pd
sf = Salesforce(username="...", password="...", security_token="...")
result = sf.query_all("""
SELECT Id, Name, StageName, Amount, CloseDate,
Probability, Type, LeadSource,
Account.Name, Account.Industry, Account.BillingCity,
Owner.Name, Owner.Email
FROM Opportunity
WHERE IsClosed = false
""")
def flatten_sf_record(record):
out = {}
for key, value in record.items():
if key == "attributes":
continue
if isinstance(value, dict) and "attributes" in value:
for sub_key, sub_val in value.items():
if sub_key != "attributes":
out[f"{key}_{sub_key}"] = sub_val
else:
out[key] = value
return out
df = pd.DataFrame([flatten_sf_record(r) for r in result["records"]])
# Amount is already a float in Salesforce (unlike Stripe's cents)
df["Amount"] = pd.to_numeric(df["Amount"], errors="coerce")
df["CloseDate"] = pd.to_datetime(df["CloseDate"], errors="coerce").dt.strftime("%Y-%m-%d")
df.to_csv("open_opportunities.csv", index=False, encoding="utf-8-sig")
print(f"Exported {len(df)} opportunities")Common issues with this API
The 'attributes' object on every record must be dropped
Salesforce attaches an 'attributes' dict to every record containing the object type and REST API URL. It's internal metadata — if you include it in json_normalize, you'll get 'attributes.type' and 'attributes.url' columns in your CSV that don't belong there. Always filter it out before flattening.
# With json_normalize — drop by column after
df = pd.json_normalize(records, sep="_")
drop = [c for c in df.columns if c.startswith("attributes") or c.endswith("_attributes")]
df = df.drop(columns=drop)Relationship fields come back as nested objects, not null
If a Contact has no Account, the Account field is null. If it has an Account, the field is a nested object. When using json_normalize, null relationships produce NaN; present relationships produce flattened columns. Both are handled correctly by the flatten_sf_record function above.
SOQL SELECT * is not supported
Unlike SQL, SOQL doesn't support SELECT *. You must specify every field you want. Use the Salesforce Object Reference or the describe API endpoint to get a list of all available fields for an object: sf.Contact.describe()['fields'].
# Get all field names for an object
fields = [f["name"] for f in sf.Contact.describe()["fields"]]
print(", ".join(fields[:20])) # preview first 20Frequently asked questions
What's the maximum number of records SOQL can return?
A single SOQL query returns up to 2,000 records. For larger datasets, use query_all() from simple_salesforce (which follows the nextRecordsUrl automatically) or set queryMore=True. The API itself has no hard record limit — pagination handles the rest.
How do I export a Salesforce report to CSV instead of querying the API?
Salesforce reports can be exported directly: open the report, click the export button, and choose 'Details only' with CSV format. This avoids the API entirely and is the fastest path for ad-hoc exports. Use the API approach for automated or scheduled exports.
Why do Salesforce IDs have 15 and 18 character variants?
Salesforce uses 15-character case-sensitive IDs in the UI and 18-character case-insensitive IDs in the API. The 18-char version has a 3-char checksum suffix. Always use 18-char IDs in exports and joins — they're safe to use case-insensitively.
Does the online converter work with Salesforce JSON?
Yes. Save the SOQL query result JSON to a file and paste it into the JSON to CSV converter. The converter flattens the records array and nested relationship objects automatically. You'll want to manually remove the attributes columns from the output.