Skip to content

The Bridge Between SQL and Excel That Every Analyst Needs

    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.

    Subscribe to Analytics in Action →

    Leave a Reply

    Your email address will not be published. Required fields are marked *