Tutorial
· Updated August 29, 2026
Export Google Play Reviews to CSV
Get Google Play reviews into a spreadsheet with correct escaping, Excel-safe encoding, and filters that keep the export to the reviews you need.
Written by Alex P.
- Google Play reviews
- CSV export
- app review analysis
- spreadsheet
- Android
Plenty of analysis still happens in a spreadsheet. Someone wants to sort last month’s one-star reviews by version, tag them by hand, and pivot the result — and that’s a completely reasonable way to work.
Getting Play Store reviews into a CSV sounds trivial and then isn’t, because review text contains commas, quotes, newlines, and emoji, and every one of those breaks a naive join(',').
This guide covers a correct export: proper escaping, an encoding Excel won’t mangle, and filtering so you export the reviews you actually want rather than everything.
This uses the Google Play Reviews API. If you haven’t pulled reviews before, start with the scraping guide.
Filter before you export, not after
The most common mistake is exporting everything and filtering in the spreadsheet. You pay for pages you throw away, and you end up with a 40,000-row file when you wanted 300.
The endpoint filters server-side. Use it:
const body = {
appIdOrUrl: 'com.spotify.music',
ratingFilter: [1, 2], // only negative
recentDays: 30, // last month
reviewsPerPage: 200, // max page size
pages: 5, // up to 1,000 reviews
sortBy: 'newest',
uniqueOnly: true, // drop duplicates
};
That’s five requests for up to 1,000 filtered reviews. The unfiltered equivalent would be many times that to find the same rows.
Escaping, correctly
RFC 4180 is the CSV spec, and it’s short. Three rules cover everything:
- Fields containing a comma, double-quote, or newline must be wrapped in double quotes
- A literal double-quote inside a quoted field is escaped by doubling it (
"→"") - Rows end with CRLF
Review text hits all three constantly. This is the whole implementation:
function csvField(value) {
if (value === null || value === undefined) return '';
const s = String(value);
// Quote if it contains a comma, quote, CR, or LF.
if (/[",\r\n]/.test(s)) {
return `"${s.replace(/"/g, '""')}"`;
}
return s;
}
function toCsv(rows, columns) {
const header = columns.map(csvField).join(',');
const body = rows.map((row) =>
columns.map((col) => csvField(row[col])).join(',')
);
return [header, ...body].join('\r\n');
}
Don’t write your own variant of this. The doubled-quote rule in particular is the one people get wrong, and the failure is silent — the file opens, the columns are just subtly shifted from the first review containing a quotation mark onward.
The Excel encoding problem
Write a UTF-8 CSV, open it in Excel on Windows, and emoji plus accented characters turn to mojibake. Reviews are full of both.
Excel needs a UTF-8 byte-order mark to detect the encoding:
import { writeFile } from 'node:fs/promises';
const BOM = '';
await writeFile('reviews.csv', BOM + csv, 'utf8');
That single prefix is the difference between Café 😞 and Café 😞 in Excel. Google Sheets and LibreOffice detect UTF-8 correctly either way, and both tolerate the BOM, so there’s no reason not to include it.
A complete export script
import { writeFile } from 'node:fs/promises';
const API_KEY = process.env.FETCHLAYER_API_KEY;
async function fetchReviews(options) {
const res = await fetch('https://api.fetchlayer.dev/playstore/reviews', {
method: 'POST',
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(options),
});
if (!res.ok) {
throw new Error(`FetchLayer returned ${res.status}: ${await res.text()}`);
}
return (await res.json()).reviews ?? [];
}
function csvField(value) {
if (value === null || value === undefined) return '';
const s = String(value);
return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}
async function exportToCsv(appId, { outFile = 'reviews.csv', ...filters } = {}) {
const reviews = await fetchReviews({
appIdOrUrl: appId,
reviewsPerPage: 200,
pages: 5,
sortBy: 'newest',
uniqueOnly: true,
...filters,
});
const columns = ['timestamp', 'rating', 'appVersion', 'reviewer', 'body'];
const header = columns.join(',');
const rows = reviews.map((r) =>
columns.map((c) => csvField(r[c])).join(',')
);
const csv = '' + [header, ...rows].join('\r\n');
await writeFile(outFile, csv, 'utf8');
console.log(`Wrote ${reviews.length} reviews to ${outFile}`);
return reviews.length;
}
await exportToCsv('com.spotify.music', {
ratingFilter: [1, 2],
recentDays: 30,
outFile: 'negative-last-30d.csv',
});
Column choices that make the spreadsheet usable
Put timestamp first. Spreadsheet software sorts on the leftmost column by default, and chronological is almost always the order you want.
Keep appVersion even if you think you don’t need it. The moment someone asks “did this start after the 8.9 release?”, it’s the column that answers the question, and re-exporting to add it is annoying.
Consider dropping reviewer. Display names are personal data under GDPR even though they’re public, and for most analysis — theme clustering, rating trends, version comparison — you don’t need them. Dropping the column is the simplest way to stay clean:
const columns = ['timestamp', 'rating', 'appVersion', 'body'];
If you do need to distinguish individual reviewers without storing names, hash them:
import { createHash } from 'node:crypto';
const anonId = (name) =>
createHash('sha256').update(name).digest('hex').slice(0, 12);
Multi-version comparison export
For release analysis, one file per version is more useful than one combined file:
async function exportByVersion(appId, versions) {
for (const version of versions) {
await exportToCsv(appId, {
appVersion: [version],
recentDays: 90,
outFile: `reviews-${version}.csv`,
});
}
}
await exportByVersion('com.example.app', ['8.7.0', '8.8.0', '8.9.0']);
Three files, each filtered server-side, is far cheaper than one big pull you slice afterwards — and it drops straight into a comparison pivot.
Timestamps in spreadsheets
The API returns ISO 8601 (2026-08-18T10:00:00.000Z). Google Sheets parses that as a date automatically. Excel sometimes treats it as text depending on locale.
If Excel is the destination, emit a format it reliably recognizes:
function excelDate(iso) {
// "2026-08-18T10:00:00.000Z" → "2026-08-18 10:00:00"
return iso.replace('T', ' ').replace(/\.\d+Z$/, '');
}
Keep it sortable — YYYY-MM-DD sorts correctly as text even when the tool doesn’t recognize it as a date, which MM/DD/YYYY does not.
Appending to an existing file
For a recurring export, append rather than rewriting, and skip the header after the first run:
import { appendFile, access } from 'node:fs/promises';
async function appendCsv(outFile, reviews, columns) {
let exists = true;
try {
await access(outFile);
} catch {
exists = false;
}
const rows = reviews.map((r) => columns.map((c) => csvField(r[c])).join(','));
const payload = exists
? '\r\n' + rows.join('\r\n')
: '' + [columns.join(','), ...rows].join('\r\n');
await appendFile(outFile, payload, 'utf8');
}
Deduplicate on timestamp + hashed reviewer before appending, or a re-run that overlaps the previous window writes the same reviews twice.
Next steps
- Google Play Reviews API — endpoint and pricing
- How to scrape Google Play reviews — full parameter reference and regression detection
- Export App Store reviews to CSV — the iOS equivalent
- Google Play Reviews API alternatives — how the options compare