🟢 The Bridge Between SQL and Excel That Every Analyst Needs


Hello Reader,

Here's a fact of analyst life: most of the people who use your analysis will never use SQL.

Instead, they'll use spreadsheets and reports.

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.

You're really just putting MORE work on their plate, making it way harder than it needs to be.

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.

Raw SQL Output (Lazy) vs. What Executives Actually 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

Now have a look at this version:

Category Total Bookings Revenue Avg Value Cancel Rate
Cultural 288 $516,346 $1,986 41%
Photography 404 $494,942 $1,532 42%

Same data. But notice the difference in the improved version:

  • commas
  • dollar signs
  • percentages
  • capitalization

10 minutes of extra effort goes a LONG way!

BTW, if you are interested in using Excel more effectively in your business analytics work, check these out:

How to Add Spreadsheet-Ready Formatting to your SQL Queries

Here's a good example of how this can be done quickly and easily using 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::text) 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(DISTINCT b.booking_id)
            FILTER (WHERE b.status = 'cancelled')::numeric
        / COUNT(DISTINCT b.booking_id) * 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;

And the results:

Category Total Bookings Revenue Avg Value Cancel Rate
Cultural 288 $516,346 $1,986 41%
Photography 404 $494,942 $1,532 48%
Safari 369 $454,140 $1,460 46%
Hiking 256 $439,317 $2,143 47%
Climbing 283 $395,880 $1,759 52%

4 SQL Functions that Make This Work

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

Notice the 'Mon YYYY' format gives you "Jan 2025" instead of "2025-01". Much friendlier in a spreadsheet.

-- 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')
LIMIT 10
Month Bookings Revenue Avg Value
Dec 2023 1 $907 $907
Jan 2024 1 $1,702 $1,702
Feb 2024 2 $1,032 $344
Apr 2024 5 $7,808 $1,301
May 2024 5 $10,787 $1,541
Jun 2024 9 $16,993 $1,545
Jul 2024 17 $35,098 $1,462
Aug 2024 22 $43,306 $1,666
Sep 2024 34 $83,304 $1,666
Oct 2024 21 $46,256 $1,542

Template 2: Customer List for Marketing

Dates formatted as "Mar 15, 2025" instead of "2025-03-15". COALESCE handles never-booked customers. Ready for a mail merge!

-- 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;
Customer Name Email Location Experience Last Booking
Joel Williams joel_williams393@icloud.com NJ Expert Nov 06, 2025
Chase Chauveau chase.chauveau794@example.org IN Beginner Nov 01, 2025
Inès Davids inès.davids393@aol.com WY Advanced Oct 29, 2025
Cécile Navarro cécile_navarro611@university.edu OH Intermediate Oct 27, 2025
Sönke Schacht sschacht691@example.com TX Beginner Oct 26, 2025
Craig Smith craig_smith117@gmail.com CA Expert Oct 25, 2025
Cécile O'Donnell cécileodonnell209@hotmail.com MN Advanced Oct 22, 2025
Kasimir Camus kasimir.camus443@company.com CA Advanced Oct 20, 2025
Charles Rose crose336@company.com AU Expert Oct 19, 2025
Pierre Harvey pierre.harvey247@yahoo.com GB Intermediate Oct 14, 2025

Template 3: Variance Report

Status indicators make the spreadsheet scannable without conditional formatting.

SELECT 
    INITCAP(TRIM(e.expedition_type::TEXT)) 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 TRIM(e.expedition_type::TEXT)
ORDER BY SUM(p.amount) DESC;
Category Revenue Status
Cultural $374,135 ◐ On Track
Photography $370,334 ◐ On Track
Hiking $351,951 ◐ On Track
Safari $348,588 ○ Below Target
Climbing $298,272 ○ Below Target

Balancing Formatting in SQL vs. Spreadsheet

There are two rules to keep in mind when formatting in SQL. You have to be careful not to overdo it.

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.

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

Brian Graves, creator of Analytics in Action

Say 👋 on X/Twitter, LinkedIn, or book a call with me. You can always reply to these emails. I check them all.

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.

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.

Starting With Data

Learn to build analytics projects with SQL, Tableau, Excel, and Python. For data analysts looking to level up their career and complete beginners looking to get started. No fluff. No theory. Just step-by-step tutorials anyone can follow.

Read more from Starting With Data

Hello Reader, A while back, I shared some tips about formatting SQL output for spreadsheets. Today we're going the other direction. Doing the kind of analysis that makes spreadsheet formulas feel clunky. Here's the kind of request that analysts get every single day: "Show me monthly revenue and how the trend is moving." If you pull monthly revenue into a spreadsheet, you'd probably create a running total column, then a 3-month moving average column, then a month-over-month change column....

Hello Reader, Most analysts panic when they get a note like this on a random Tuesday afternoon (from the CEO): "Revenue feels off this quarter. Can you figure out what's happening before the board call at 4:30?" Great. You have 90 minutes. No time to build a comprehensive analysis. No time to make it pretty. You need to diagnose the problem (super fast) and deliver a clear answer. Lucky you. You're going to learn the way a seasoned pro handles this situation. I call it The Gordon Ramsay...

Hey Reader -- I'm trying something new this week! Can you take a minute to fill out this quick survey? Thanks in advance! Okay, on to this week's topic: A common question I get from readers is some version of: "This Summit Adventures stuff is great, but I work in healthcare (or SaaS, or retail, or finance). How does this apply to me?" The honest answer: every SQL pattern you've learned in this newsletter translates directly to your industry. The table names change. So do column names. The...