Hello ,
Here’s a fact of analyst life: most of the people who use your analysis will never see SQL. They’ll see a spreadsheet.
Your director doesn’t open pgAdmin. Your marketing team doesn’t connect to the database. They open the Excel file or Google Sheet you sent them. And if the data you exported requires 20 minutes of cleanup before it’s usable — reformatting dates, splitting columns, fixing number formats — you’ve done extra work that didn’t need to happen.
The good news: you can format your query output so it’s already clean when it lands in a spreadsheet. A few SQL tricks save significant cleanup time.
The Problem: Raw SQL Output vs. What Executives Want
Here’s typical raw output from a revenue analysis:
expedition_type | total_bookings | total_revenue | avg_value | cancel_rate cultural | 288 | 516346.23 | 1986.41 | 0.406 photography | 404 | 494942.18 | 1532.24 | 0.423
Here’s what your executive expects to see in the spreadsheet:
Category | Total Bookings | Revenue | Avg Value | Cancel Rate Cultural | 288 | $516,346 | $1,986 | 41% Photography | 404 | $494,942 | $1,532 | 42%
The difference: dollar signs, commas, percentages, capitalization. Small details that communicate professionalism.
The Query: Spreadsheet-Ready Formatting
Here’s what I ran against the Summit Adventures database (the fake adventure tourism company I created to help people learn business analytics):
-- Executive summary: spreadsheet-ready format
SELECT
INITCAP(e.expedition_type) AS "Category",
COUNT(DISTINCT b.booking_id) AS "Total Bookings",
'$' || TO_CHAR(SUM(p.amount), 'FM999,999') AS "Revenue",
'$' || TO_CHAR(
ROUND(AVG(p.amount)::numeric, 0), 'FM999,999'
) AS "Avg Value",
ROUND(
COUNT(*) FILTER (WHERE b.status = 'cancelled')::numeric
/ COUNT(*) * 100, 0
)::text || '%' AS "Cancel Rate"
FROM expeditions e
INNER JOIN expedition_instances ei
ON e.expedition_id = ei.expedition_id
INNER JOIN bookings b
ON ei.instance_id = b.instance_id
LEFT JOIN payments p ON b.booking_id = p.booking_id
AND p.payment_status = 'completed'
GROUP BY e.expedition_type
ORDER BY SUM(p.amount) DESC NULLS LAST;
Results:
| Category | Total Bookings | Revenue | Avg Value | Cancel Rate |
|———-|—————|———|———–|————-|
| Cultural | 288 | $516,346 | $1,986 | 34% |
| Photography | 404 | $494,942 | $1,532 | 42% |
| Safari | 369 | $454,140 | $1,460 | 40% |
| Hiking | 256 | $439,317 | $2,143 | 41% |
| Climbing | 283 | $395,880 | $1,759 | 46% |
This pastes directly into a spreadsheet. No reformatting needed.
The Formatting Toolkit
Here are the functions that make this work:
1. INITCAP — Capitalize First Letters
INITCAP('cultural photography') → 'Cultural Photography'
Turns lowercase database values into proper display names.
2. TO_CHAR — Number Formatting
TO_CHAR(516346.23, 'FM999,999,999') → '516,346'
`FM` removes padding. `999,999` adds comma separators. Use `999,999.00` if you want decimals.
3. Dollar Signs and Percent Signs
-- Dollar formatting '$' || TO_CHAR(amount, 'FM999,999') AS revenue -- Percentage formatting ROUND(rate * 100, 0)::text || '%' AS cancel_rate
Concatenation with `||` adds the symbols. Simple and effective.
4. Column Aliases With Quotes
SELECT amount AS "Total Revenue" -- Preserves spaces and casing SELECT amount AS total_revenue -- Lowercase, no spaces
Double-quoted aliases keep proper column headers for your spreadsheet.
Three Export-Ready Query Templates
Template 1: Monthly Summary Report
-- Monthly metrics for executive review
SELECT
TO_CHAR(b.booking_date, 'Mon YYYY') AS "Month",
COUNT(DISTINCT b.booking_id) AS "Bookings",
'$' || TO_CHAR(SUM(p.amount), 'FM999,999') AS "Revenue",
'$' || TO_CHAR(
ROUND(AVG(p.amount)::numeric, 0), 'FM999,999'
) AS "Avg Value"
FROM bookings b
INNER JOIN payments p ON b.booking_id = p.booking_id
WHERE p.payment_status = 'completed'
AND b.status IN ('completed', 'confirmed')
GROUP BY TO_CHAR(b.booking_date, 'Mon YYYY'),
TO_CHAR(b.booking_date, 'YYYY-MM')
ORDER BY TO_CHAR(b.booking_date, 'YYYY-MM');
Notice the `’Mon YYYY’` format — you get “Jan 2025” instead of “2025-01”. Much friendlier in a spreadsheet.
Template 2: Customer List for Marketing
-- Clean customer export for email campaigns
SELECT
c.first_name || ' ' || c.last_name AS "Customer Name",
c.email AS "Email",
COALESCE(c.state, c.country, 'Unknown') AS "Location",
INITCAP(c.experience_level::text) AS "Experience",
COALESCE(
TO_CHAR(MAX(b.booking_date), 'Mon DD, YYYY'),
'Never Booked'
) AS "Last Booking"
FROM customers c
LEFT JOIN bookings b ON c.customer_id = b.customer_id
AND b.status IN ('completed', 'confirmed')
GROUP BY c.customer_id, c.first_name, c.last_name,
c.email, c.state, c.country, c.experience_level
ORDER BY MAX(b.booking_date) DESC NULLS LAST;
Dates formatted as “Mar 15, 2025” instead of “2025-03-15”. COALESCE handles never-booked customers. Ready for a mail merge.
Template 3: Variance Report
-- Category performance with indicators
SELECT
INITCAP(e.expedition_type) AS "Category",
'$' || TO_CHAR(SUM(p.amount), 'FM999,999') AS "Revenue",
CASE
WHEN SUM(p.amount) >= 450000 THEN '● Above Target'
WHEN SUM(p.amount) >= 350000 THEN '◐ On Track'
ELSE '○ Below Target'
END AS "Status"
FROM expeditions e
INNER JOIN expedition_instances ei
ON e.expedition_id = ei.expedition_id
INNER JOIN bookings b ON ei.instance_id = b.instance_id
INNER JOIN payments p ON b.booking_id = p.booking_id
WHERE p.payment_status = 'completed'
AND b.status IN ('completed', 'confirmed')
GROUP BY e.expedition_type
ORDER BY SUM(p.amount) DESC;
Status indicators make the spreadsheet scannable without conditional formatting.
The Trade-Off: Formatting in SQL vs. Spreadsheet
There’s a balance to strike:
Format in SQL when:
Results go directly into a presentation or report
You’re sharing a one-time export with non-technical colleagues
The formatting is straightforward (dollars, percentages, dates)
Format in the spreadsheet when:
The data will be used for further calculations (formatted strings can’t be summed)
You need conditional formatting, charts, or pivot tables
Multiple people will manipulate the data
A good rule of thumb: if someone will use your output as-is, format it in SQL. If they’ll do further analysis on it, export clean numbers and let them format.
Once you can write the query and format the export, the next step is automating that export. That’s where tools like Python scripts become your multiplier — running the query, formatting the output, and emailing the spreadsheet automatically. But that’s a topic for another day.
Common Mistakes to Avoid
Mistake 1: Formatting numbers as strings when they need to stay numeric
`’$’ || TO_CHAR(amount, ‘FM999,999’)` creates a text string, not a number. If someone needs to sort or sum that column in Excel, they’ll have to strip the dollar signs first. Know your audience.
Mistake 2: Inconsistent formatting across columns
If some revenue columns have dollar signs and others don’t, it looks sloppy. Apply formatting consistently within each report.
Mistake 3: Over-formatting
`TO_CHAR(amount, ‘$999,999,999.999999’)` with six decimal places is worse than no formatting. Match the precision to the audience. Executives want round numbers.
Try This Today
Take your most common query and add three formatting improvements:
1. INITCAP on any display names
2. TO_CHAR with commas on numeric values
3. Double-quoted column aliases for proper headers
The first time your colleague opens the export and says “this is perfect, I don’t need to change anything” — that’s the goal.
Until next time,
Brian ([say hi on twitter!](https://twitter.com/briangraves))
—
P.S. Presenting data for business impact is a key theme throughout SQL for Business Impact. Whether it’s formatting exports or framing insights for executives, the course teaches you the full workflow from query to decision. Check it out at [sqlforbusinessimpact.com](https://sqlforbusinessimpact.com).
P.P.S. What’s your least favorite part of the SQL-to-spreadsheet workflow? Hit reply and let me know — I might build a solution for it. I read every response.
Want More SQL Insights Like This?
Join thousands of analysts getting weekly tips on turning data into business impact.
