ChatGPT is good at building tables. Ask it to compare pricing plans, summarise research findings, list product specifications, or break down a content calendar, and it hands back clean rows and columns in seconds. Then you try to get the table out of the chat window. There is a Copy table button and nothing else — no download, no export, no file.
So you copy, open Excel or Google Sheets, paste, and spend the next few minutes rebuilding columns that collapsed into one. This post shows you how to export a ChatGPT table to CSV in one click instead. The full userscript is on this page, ready to paste.
How to export a ChatGPT table to CSV in one click
A userscript is a small piece of JavaScript that runs on a page you visit, added by a browser extension rather than by the site owner. It can change what a page looks like and add controls the site never shipped.
This one puts two buttons directly into the toolbar ChatGPT already draws above each table — the same row as Copy table. It does not add a bar of its own, and it does not touch the table itself, so nothing shifts or breaks in the layout.
The buttons appear on their own whenever a table finishes rendering. There is nothing to configure.
The CSV button
Clicking CSV downloads the table as a spreadsheet file. It opens in Microsoft Excel, Google Sheets, LibreOffice Calc, Airtable, or anything else that reads CSV. Columns are separated correctly and the file is usable the moment it lands in your downloads folder.
CSV is a plain-text format where each row is a line and each cell is separated by a comma. That sounds trivial until a cell contains a comma of its own. The script quotes a cell only when it has to — when the text contains a comma, a quote mark, or a line break — and doubles any internal quotes, which is the behaviour set out in the RFC 4180 specification for comma-separated values.
The file gets named after the nearest heading above the table in the conversation. If ChatGPT wrote a "Pricing comparison" heading, you get Pricing_comparison.csv. If there is no heading to borrow, you get table_export.csv.
The MD button
Clicking MD copies a clean Markdown version of the table to your clipboard. Paste it straight into Notion, Obsidian, GitHub, GitLab, a documentation platform, or a static site generator. The columns are padded to equal width, so the raw Markdown stays readable in a text editor rather than arriving as a jagged mess of pipes.
If the clipboard write fails — the tab was not focused, or the browser refused permission — the script does not silently do nothing. It falls back to downloading the table as a .md file and tells you so. Both outcomes show a small confirmation at the bottom of the screen.
Why copying the table by hand breaks it
When you drag-select a table in a browser, you are not copying a table. You are copying rendered text with whatever whitespace the layout happened to produce. Excel then guesses where the columns were. Sometimes it guesses right. More often you get everything in column A, or one row split across three because a cell wrapped.
The script sidesteps the guessing. It reads the underlying <table> element — the real th and td cells, in order — and writes the file from that. There is no interpretation step to get wrong.
It is worth being clear about what the script touches. It reads a table that has already loaded in your own browser, in your own session. It makes no network requests, calls no API, and logs in nowhere. Whether any particular site's terms of service permit automation is a judgement you make about that site, not something a script can make for you.
The Tampermonkey userscript, ready to paste
Tampermonkey is the extension that runs userscripts. It is free, it works in Chrome, Firefox, Edge, Safari and Opera, and you can get it from the official Tampermonkey download page.
Install it in about a minute
- Install Tampermonkey in your browser.
- Open the Tampermonkey dashboard and create a new script.
- Delete the placeholder code and paste the script below. You can also download the raw userscript file if you would rather not copy from the page.
- Save with Ctrl+S, or Cmd+S on a Mac.
- Reload ChatGPT.
It asks for no special permissions — the header line @grant none means the script gets no extension privileges beyond running on the page. Every line is on this page, so you can read exactly what it does before you install it.
Show Image
The script
javascript
// ==UserScript==
// @name ChatGPT — Table Export (CSV & Markdown)
// @namespace https://github.com/you/chatgpt-table-export
// @version 2.0.0
// @description Adds CSV and Markdown export buttons inside the "Copy table" toolbar — no layout breaking
// @author You
// @match https://chatgpt.com/*
// @match https://chat.openai.com/*
// @grant none
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
/* ─────────────────────────────────────────
* STYLES
* ───────────────────────────────────────── */
const STYLE = `
.texp-toolbar {
display: inline-flex;
align-items: center;
gap: 3px;
flex-wrap: nowrap;
}
.texp-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 3px;
height: 26px;
padding: 0 7px;
border: none;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.03em;
cursor: pointer;
transition: background 0.15s, transform 0.1s, box-shadow 0.15s;
user-select: none;
white-space: nowrap;
outline: none;
flex-shrink: 0;
position: relative;
z-index: 30;
}
.texp-btn.texp-csv {
background: rgba(16, 163, 127, 0.13);
color: #10a37f;
}
.texp-btn.texp-csv:hover {
background: rgba(16, 163, 127, 0.24);
box-shadow: 0 0 0 1px rgba(16,163,127,0.38);
transform: translateY(-1px);
}
.texp-btn.texp-md {
background: rgba(139, 92, 246, 0.13);
color: #8b5cf6;
}
.texp-btn.texp-md:hover {
background: rgba(139, 92, 246, 0.24);
box-shadow: 0 0 0 1px rgba(139,92,246,0.38);
transform: translateY(-1px);
}
.texp-btn:active { transform: translateY(0) !important; }
.texp-btn.texp-done {
background: rgba(16, 163, 127, 0.28) !important;
color: #10a37f !important;
pointer-events: none;
}
.texp-toast {
position: fixed;
bottom: 28px;
left: 50%;
transform: translateX(-50%) translateY(10px);
background: #1c1c1e;
color: #f5f5f7;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 13px;
font-weight: 500;
padding: 9px 18px;
border-radius: 12px;
box-shadow: 0 4px 24px rgba(0,0,0,0.40);
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease, transform 0.2s ease;
z-index: 999999;
white-space: nowrap;
}
.texp-toast.texp-show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
`;
const styleEl = document.createElement('style');
styleEl.textContent = STYLE;
document.head.appendChild(styleEl);
/* ─────────────────────────────────────────
* TOAST
* ───────────────────────────────────────── */
const toastEl = document.createElement('div');
toastEl.className = 'texp-toast';
document.body.appendChild(toastEl);
let toastTimer;
function showToast(msg) {
clearTimeout(toastTimer);
toastEl.textContent = msg;
toastEl.classList.add('texp-show');
toastTimer = setTimeout(() => toastEl.classList.remove('texp-show'), 2400);
}
/* ─────────────────────────────────────────
* ICONS
* ───────────────────────────────────────── */
const ICON_CSV = `<svg width="11" height="11" viewBox="0 0 16 16" fill="none">
<rect x="1.5" y="3" width="13" height="10" rx="1.5" stroke="currentColor" stroke-width="1.6"/>
<line x1="1.5" y1="7" x2="14.5" y2="7" stroke="currentColor" stroke-width="1.3"/>
<line x1="5.5" y1="3" x2="5.5" y2="13" stroke="currentColor" stroke-width="1.3"/>
<line x1="10.5" y1="3" x2="10.5" y2="13" stroke="currentColor" stroke-width="1.3"/>
</svg>`;
const ICON_MD = `<svg width="11" height="11" viewBox="0 0 16 16" fill="none">
<rect x="1.5" y="2.5" width="13" height="11" rx="1.5" stroke="currentColor" stroke-width="1.6"/>
<path d="M4 11V6l2 2.5L8 6v5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10.5 9L12 11l1.5-2" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="12" y1="5.5" x2="12" y2="11" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
</svg>`;
/* ─────────────────────────────────────────
* TABLE PARSING
* ───────────────────────────────────────── */
function parseTable(tableEl) {
const rows = [];
tableEl.querySelectorAll('tr').forEach(tr => {
const cells = [];
tr.querySelectorAll('th, td').forEach(td => {
cells.push(td.innerText.replace(/\s+/g, ' ').trim());
});
if (cells.length) rows.push(cells);
});
return rows;
}
function toCSV(rows) {
return rows.map(row =>
row.map(c => {
const s = c.replace(/"/g, '""');
return /[",\n\r]/.test(c) ? `"${s}"` : s;
}).join(',')
).join('\r\n');
}
function toMarkdown(rows) {
if (!rows.length) return '';
const cols = Math.max(...rows.map(r => r.length));
const padded = rows.map(r => {
const copy = [...r];
while (copy.length < cols) copy.push('');
return copy;
});
const widths = Array.from({ length: cols }, (_, i) =>
Math.max(3, ...padded.map(r => r[i].length))
);
const line = r => '| ' + r.map((c, i) => c.padEnd(widths[i])).join(' | ') + ' |';
const sep = '| ' + widths.map(w => '-'.repeat(w)).join(' | ') + ' |';
const [head, ...body] = padded;
return [line(head), sep, ...body.map(line)].join('\n');
}
/* ─────────────────────────────────────────
* DOWNLOAD
* ───────────────────────────────────────── */
function download(content, filename, mime) {
const a = Object.assign(document.createElement('a'), {
href: URL.createObjectURL(new Blob([content], { type: mime })),
download: filename,
});
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
}
/* ─────────────────────────────────────────
* FILENAME HINT
* ───────────────────────────────────────── */
function guessTitle(tableEl) {
let el = tableEl.closest('[class*="tableContainer"]') || tableEl.parentElement;
while (el && el !== document.body) {
let sib = el.previousElementSibling;
while (sib) {
if (/^h[1-6]$/i.test(sib.tagName)) {
return sib.innerText.trim()
.replace(/[^a-z0-9]+/gi, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 40) || 'table';
}
sib = sib.previousElementSibling;
}
el = el.parentElement;
}
return 'table_export';
}
/* ─────────────────────────────────────────
* FLASH BUTTON
* ───────────────────────────────────────── */
function flash(btn, origHTML) {
btn.innerHTML = '✓';
btn.classList.add('texp-done');
setTimeout(() => {
btn.innerHTML = origHTML;
btn.classList.remove('texp-done');
}, 1800);
}
/* ─────────────────────────────────────────
* BUILD BUTTON GROUP
* ───────────────────────────────────────── */
function buildGroup(tableEl) {
const wrap = document.createElement('span');
wrap.className = 'texp-toolbar';
wrap.setAttribute('data-texp', '1');
const csvHTML = `${ICON_CSV} CSV`;
const csv = document.createElement('button');
csv.className = 'texp-btn texp-csv';
csv.title = 'Download as CSV';
csv.innerHTML = csvHTML;
csv.addEventListener('click', e => {
e.stopPropagation();
const rows = parseTable(tableEl);
if (!rows.length) return showToast('⚠️ Table is empty');
const name = guessTitle(tableEl) + '.csv';
download(toCSV(rows), name, 'text/csv;charset=utf-8;');
flash(csv, csvHTML);
showToast(`📥 Saved "${name}"`);
});
const mdHTML = `${ICON_MD} MD`;
const md = document.createElement('button');
md.className = 'texp-btn texp-md';
md.title = 'Copy as Markdown';
md.innerHTML = mdHTML;
md.addEventListener('click', async e => {
e.stopPropagation();
const rows = parseTable(tableEl);
if (!rows.length) return showToast('⚠️ Table is empty');
const text = toMarkdown(rows);
try {
await navigator.clipboard.writeText(text);
flash(md, mdHTML);
showToast('📋 Markdown copied!');
} catch {
const name = guessTitle(tableEl) + '.md';
download(text, name, 'text/markdown;charset=utf-8;');
showToast(`📥 Saved "${name}"`);
}
});
wrap.appendChild(csv);
wrap.appendChild(md);
wrap.style.transform = 'translateX(-200px) translateY(-10px)';
return wrap;
}
/* ─────────────────────────────────────────
* INJECT
*
* DOM structure (ChatGPT):
* .TyagGW_tableWrapper ← wrapper
* table ← the data
* .relative.h-0 ← overlay anchor
* .absolute.end-0.flex ← THE TOOLBAR (flex row)
* span[data-state]
* button[aria-label="Copy table"]
*
* We append our group into .absolute.end-0.flex
* so it's a sibling of the <span>, staying inside
* the same flex row and never touching the table.
* ───────────────────────────────────────── */
function inject() {
document.querySelectorAll('button[aria-label="Copy table"]').forEach(copyBtn => {
// Walk up to the flex toolbar row
// It's the .absolute div that contains the copy button
const toolbar = copyBtn
.closest('.absolute');
if (!toolbar) return;
// Skip if already injected
if (toolbar.querySelector('[data-texp]')) return;
// Find the associated <table>
const wrapper = copyBtn.closest('[class*="tableWrapper"]');
if (!wrapper) return;
const tableEl = wrapper.querySelector('table');
if (!tableEl) return;
// Append inside the same flex row
toolbar.prepend(buildGroup(tableEl));
});
}
/* ─────────────────────────────────────────
* OBSERVE + INIT
* ───────────────────────────────────────── */
new MutationObserver(inject).observe(document.body, {
childList: true,
subtree: true,
});
inject();
// SPA navigation guard
let lastUrl = location.href;
setInterval(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
setTimeout(inject, 900);
}
}, 500);
})();
The Markdown copy runs through the browser Clipboard API documented by MDN, which is why the try block has a catch behind it. Browsers refuse clipboard writes in some contexts, and the fallback download covers those.
If the buttons don't show up
Four things account for almost every case.
The reply is still streaming. ChatGPT draws its Copy table toolbar once the table is complete, and the script hooks that toolbar, so there is nothing to attach to until the response finishes.
The script is toggled off. Open the Tampermonkey dashboard and check that it is enabled for the tab you are in.
You just switched conversations. The script re-scans about a second after the URL changes, so give it a moment before assuming it failed.
ChatGPT changed its markup. The script finds tables by looking for button[aria-label="Copy table"] and a wrapper element whose class contains tableWrapper. If OpenAI renames either, injection stops until those two selectors are updated. That is the trade-off with any userscript: you are hooking an interface you do not control.
Opening the file in Excel or Google Sheets
Once you export a ChatGPT table to Excel or Sheets, one problem turns up often enough to be worth naming: accented characters arriving as garbage. café becomes café.
That is an encoding mismatch. The script writes the file as UTF-8 and says so in the file's MIME type, and Google Sheets respects that — open the download directly, or bring it in through the Google Sheets file import instructions. Excel on Windows is the awkward one. Double-click a CSV and it often ignores the declared encoding and falls back to a regional character set.
Two fixes. In Excel, use Data → From Text/CSV rather than double-clicking, and choose UTF-8 in the import dialog. Or make the file announce itself by adding a byte order mark — three invisible bytes at the start that identify the encoding. That is a one-line edit in the CSV click handler:
download('\uFEFF' + toCSV(rows), name, 'text/csv;charset=utf-8;');
Add it to the CSV button only. A byte order mark in a Markdown file will show up as stray characters in some editors.
Who this saves the most time for
Copying one table by hand takes a minute. The question is how many times a week you do it. If ChatGPT is part of how you work, tables come up constantly — comparisons, summaries, keyword lists, feature breakdowns, schedules, budget frameworks. Ask for anything structured and you get a table.
SEO and content people
Keyword tables, content calendars, competitor comparisons and topic clusters land in a spreadsheet ready for analysis or to send to a client.
Researchers and analysts
Literature summaries, data comparisons and structured notes drop into Notion databases or Obsidian vaults as Markdown with no reformatting.
Developers and technical writers
Tables in README files, API docs and wikis have to be Markdown. Rebuilding that syntax by hand across several tables is exactly the kind of work worth removing.
Scraping and automation work
ChatGPT is useful for structuring and sanity-checking data mid-pipeline. Pulling it out as CSV means it can feed the next stage — a Python script, a database import, a reporting tool — without a manual hop.
Business users and consultants
Market research, competitive analysis and client deliverables usually end up in a spreadsheet anyway. This removes the reformatting step between the answer and the document.
When a script stops being enough
A userscript is the right tool when the data is already on your screen and you only need it in a file. It stops being the right tool the moment you need data collected on a schedule, from many pages, behind a login, or written into a database instead of a downloads folder.
That is where custom work starts, and it is most of what I do — Python and Playwright bots, Chrome extensions and userscripts built to spec, and pipelines that clean the output and push it into PostgreSQL, Google Sheets or Airtable. You can see the automation and scraping services I offer, or read how I work and what I build.
If you have a specific site and a specific set of fields you need out of it, tell me what you need scraped and I will tell you whether it is a two-hour job or a two-week one before you pay anything. Paid work goes through Fiverr.
Questions I get asked
Does it work on chatgpt.com and chat.openai.com?
Both. The two @match lines at the top of the script cover each domain.
Will it export more than one table?
Yes. Every table in the conversation gets its own pair of buttons and exports independently. The script skips any toolbar it has already touched, so you never end up with duplicate buttons.
Does it send my data anywhere?
No. Everything happens in the tab. There are no network requests in the script, and @grant none means it asks for no extension permissions.
Can I change the filename?
The file is named after the nearest heading above the table, falling back to table_export. Edit the guessTitle function to change that behaviour.
Why do the buttons look green and purple?
Those are the two colours set in the .texp-csv and .texp-md style rules near the top. Change the hex values and reload ChatGPT.
More scripts, teardowns and automation write-ups go up in the DatafetchPro article archive.
