C# Export to Excel: Keep Your Dates, Numbers, and Leading Zeros

An Excel export can open without an error and still be wrong. An identifier loses its leading zeros. A date sorts as text. A long reference number comes back with different digits at the end. The download succeeded, but the person using it has a problem.

I wrote about exporting MySafeInfo data to Excel in C# back in 2016. That example used ASP.NET Web Forms and EPPlus. For this one, I wanted a complete, current example with explicit decisions about what goes into each cell.

The source is the States dataset from MySafeInfo, the reference-data service I built. It is available in full without an account or API key, so you can run this example as written. The workbook uses ClosedXML, an MIT-licensed library that creates real .xlsx files without requiring Excel to be installed.

The important choices are small enough to explain before the code:

  • Identifiers are text. The source ID identifies a record; nobody needs to add the IDs together. Names, abbreviations, and capitals are text too.
  • The source's Statehood value is a numeric year. It stays a number. Turning 1819 into January 1, 1819 would invent information the API did not provide.
  • The retrieval timestamp is an Excel date value with a display format. Its label explicitly says UTC, because the spreadsheet cell does not retain a time-zone offset.
  • An empty capital becomes a blank cell. A zero in the source year remains zero. Those are different values, and the exporter should not quietly decide they mean the same thing.

At the time of testing, the States endpoint returned 56 records, including territories and Washington, D.C. The code exports the records it receives; it does not assume this is a list of exactly 50 states or reinterpret the source's year values.

This is a .NET 10 console application using ClosedXML 0.105.1, the version tested here. Create the project and add the package:

dotnet new console --name MySafeInfoExcel --framework net10.0
cd MySafeInfoExcel
dotnet add package ClosedXML --version 0.105.1

Replace Program.cs with the following. It fetches the data, validates the fields, and writes two worksheets: the records themselves and a short record of where and when the data was retrieved.

using System.Globalization;
using System.Net.Http.Headers;
using System.Text.Json;
using System.Text.Json.Serialization;
using ClosedXML.Excel;

try
{
    if (args.Length > 1)
        throw new ArgumentException("Usage: dotnet run -- [output.xlsx]");

    // One client for this one-shot console application.
    using var http = new HttpClient(new HttpClientHandler
    {
        AllowAutoRedirect = false
    })
    {
        Timeout = TimeSpan.FromSeconds(30),
        MaxResponseContentBufferSize = 1_048_576
    };
    http.DefaultRequestHeaders.Accept.ParseAdd("application/json");
    http.DefaultRequestHeaders.UserAgent.ParseAdd("MySafeInfoExcelExample/1.0");

    // Optional: States is available in full without a key.
    string? key = Environment.GetEnvironmentVariable("MYSAFEINFO_API_KEY");
    if (!string.IsNullOrWhiteSpace(key))
        http.DefaultRequestHeaders.Authorization =
            new AuthenticationHeaderValue("Bearer", key.Trim());

    string path = Path.GetFullPath(args.Length == 1 ? args[0] : "states.xlsx");
    int count = await StateExport.RunAsync(http, path);
    Console.WriteLine($"Wrote {count} records to {path}");
    return 0;
}
catch (Exception ex) when (ex is HttpRequestException or JsonException
    or IOException or UnauthorizedAccessException or ArgumentException
    or InvalidDataException or FormatException or OperationCanceledException)
{
    Console.Error.WriteLine($"Export failed: {ex.Message}");
    return 1;
}

public static class StateExport
{
    public const string SourceUrl =
        "https://mysafeinfo.com/api/data/states?format=json&full=true";
    public const int MaxRows = 10_000;

    public static async Task<int> RunAsync(HttpClient http, string outputPath,
        CancellationToken cancellationToken = default)
    {
        string path = Path.GetFullPath(outputPath);
        if (!string.Equals(Path.GetExtension(path), ".xlsx",
            StringComparison.OrdinalIgnoreCase))
            throw new ArgumentException("The output file must end in .xlsx.");
        if (File.Exists(path))
            throw new IOException("The output file already exists. Choose a new name.");

        // Buffering keeps the client's response-size limit in effect.
        using var response = await http.GetAsync(SourceUrl,
            HttpCompletionOption.ResponseContentRead, cancellationToken);
        response.EnsureSuccessStatusCode();

        await using var json = await response.Content.ReadAsStreamAsync(cancellationToken);
        var rows = await JsonSerializer.DeserializeAsync<List<StateRow>>(
            json, new JsonSerializerOptions { RespectNullableAnnotations = true },
            cancellationToken) ?? throw new InvalidDataException("The API returned null.");
        Validate(rows);
        DateTimeOffset retrievedUtc = DateTimeOffset.UtcNow;

        // Finish the workbook before giving it its final name.
        string temporary = Path.Combine(Path.GetDirectoryName(path)!,
            $".{Guid.NewGuid():N}.tmp");
        try
        {
            using (var file = new FileStream(temporary, FileMode.CreateNew,
                FileAccess.ReadWrite, FileShare.None))
            {
                WriteWorkbook(rows, file, retrievedUtc);
            }
            cancellationToken.ThrowIfCancellationRequested();
            File.Move(temporary, path); // Does not overwrite an existing file.
        }
        finally
        {
            if (File.Exists(temporary))
                File.Delete(temporary);
        }
        return rows.Count;
    }

    public static void WriteWorkbook(IReadOnlyList<StateRow> rows,
        Stream output, DateTimeOffset retrievedUtc)
    {
        Validate(rows);
        using var workbook = new XLWorkbook();
        var sheet = workbook.Worksheets.Add("States");
        string[] headers = ["Source ID", "State / territory", "Abbreviation",
            "Capital", "Statehood (source year)"];
        for (int column = 0; column < headers.Length; column++)
            sheet.Cell(1, column + 1).Value = headers[column];

        for (int index = 0; index < rows.Count; index++)
        {
            var item = rows[index];
            int row = index + 2;
            sheet.Cell(row, 1).Value = item.Id.ToString(CultureInfo.InvariantCulture);
            // Rich text preserves literal leading apostrophes in source strings.
            sheet.Cell(row, 2).GetRichText().AddText(item.StateName);
            sheet.Cell(row, 3).GetRichText().AddText(item.Abbreviation);
            if (item.Capital.Length > 0)
                sheet.Cell(row, 4).GetRichText().AddText(item.Capital);
            sheet.Cell(row, 5).Value = item.Statehood;
        }

        sheet.Range(2, 1, rows.Count + 1, 1).Style.NumberFormat.Format = "@";
        sheet.Range(2, 5, rows.Count + 1, 5).Style.NumberFormat.Format = "0";
        sheet.Range(1, 1, rows.Count + 1, 5).CreateTable("StatesTable");
        sheet.SheetView.FreezeRows(1);
        sheet.Column(1).Width = 12;
        sheet.Column(2).Width = 30;
        sheet.Column(3).Width = 16;
        sheet.Column(4).Width = 26;
        sheet.Column(5).Width = 26;

        var info = workbook.Worksheets.Add("Export info");
        info.Cell("A1").Value = "Source";
        info.Cell("B1").Value = SourceUrl;
        info.Cell("A2").Value = "Retrieved (UTC)";
        info.Cell("B2").Value = retrievedUtc.UtcDateTime;
        info.Cell("B2").Style.NumberFormat.Format = "yyyy-mm-dd hh:mm:ss";
        info.Cell("A3").Value = "Records";
        info.Cell("B3").Value = rows.Count;
        info.Cell("A4").Value = "Scope";
        info.Cell("B4").Value = "Includes territories and Washington, D.C.";
        info.Cell("A5").Value = "Year values";
        info.Cell("B5").Value = "Copied from the source, including zero; not full dates.";
        info.Column(1).Width = 22;
        info.Column(2).Width = 80;
        info.Range("A1:A5").Style.Font.Bold = true;
        workbook.SaveAs(output);
    }

    private static void Validate(IReadOnlyList<StateRow> rows)
    {
        if (rows.Count is 0 or > MaxRows)
            throw new InvalidDataException($"Expected 1 to {MaxRows} records.");

        var ids = new HashSet<int>();
        foreach (var item in rows)
        {
            if (item is null || item.Id <= 0 || !ids.Add(item.Id)
                || string.IsNullOrWhiteSpace(item.StateName)
                || string.IsNullOrWhiteSpace(item.Abbreviation)
                || item.Capital is null || item.Statehood is < 0 or > 9999
                || item.StateName.Length > 32_767
                || item.Abbreviation.Length > 32_767
                || item.Capital.Length > 32_767)
                throw new InvalidDataException("Unexpected States data; no export was saved.");
        }
    }
}

public sealed record StateRow
{
    [JsonPropertyName("ID")]
    public required int Id { get; init; }
    public required string StateName { get; init; }
    public required string Abbreviation { get; init; }
    public required string Capital { get; init; }
    public required int Statehood { get; init; }
}

Run it with an output filename:

dotnet run -- states.xlsx

The destination directory must already exist. If the file exists, the program refuses to overwrite it; use another filename. A failed request or invalid response produces an error and a nonzero exit code. The final filename is created only after the workbook has been saved successfully.

No key is needed for this dataset. If you have a pass, the optional MYSAFEINFO_API_KEY environment variable sends your key in the authorization header. Keep it out of source code and URLs. The request also uses full=true, which asks MySafeInfo to fail rather than silently return a sample when full access requires a key. Other datasets have different fields and need their own model and column mapping; changing the URL alone is not enough. The API documentation covers access and request limits.

The States IDs do not contain leading zeros. To see why the text decision matters elsewhere, this small, separate workbook uses a postal code and a long reference number:

using ClosedXML.Excel;

using var workbook = new XLWorkbook();
var sheet = workbook.Worksheets.Add("Text examples");
sheet.Cell("A1").Value = "Postal code";
sheet.Cell("A2").Value = "00501";
sheet.Cell("B1").Value = "Reference";
sheet.Cell("B2").Value = "123456789012345678";
sheet.Range("A2:B2").Style.NumberFormat.Format = "@";
workbook.SaveAs("text-examples.xlsx");

Run that separately, with an unused filename. Both values are assigned as strings. The text format makes the intent explicit, but formatting is not what preserves the original characters. If the application already converted 00501 to the number 501, changing the cell's format cannot recover what was lost. Excel also has a 15-digit limit on numeric precision, so long identifiers belong in text cells.

Dates work the other way around. The export assigns a DateTime to the timestamp cell, then gives it the format yyyy-mm-dd hh:mm:ss. It does not call ToString() and hand Excel a date-shaped piece of text. As the ClosedXML formatting documentation explains, a number format changes presentation, not the underlying value.

Testing caught another detail worth keeping: assigning a string through Value can treat a leading apostrophe as Excel's text prefix. The main export uses GetRichText().AddText() for the API's text fields so a literal apostrophe survives. The tests also check that strings beginning with =, +, -, and @ remain text. Imported text never gets assigned to FormulaA1.

The verification went beyond checking that a file appeared. The final example was run against the live API without a key, the saved workbook was reopened, and its structure was checked with the Open XML SDK validator. Automated cases covered:

  • Text and numeric cell types, leading zeros, an 18-digit reference, Unicode, literal apostrophes, and formula-like text.
  • UTC timestamp conversion and results under U.S., German, and French culture settings.
  • Missing fields, nulls, duplicate IDs, invalid JSON, oversized responses, and too many records.
  • HTTP errors, including 429, cancellation, timeouts, and protecting an existing destination file, including one created during the request.

This is deliberately a bounded export. The HTTP response is capped at one MiB, the request times out after 30 seconds, and validation rejects more than 10,000 rows. Those are example limits, not promises about how much memory a workbook will use. ClosedXML builds the workbook in memory. For a large export or a web application serving concurrent downloads, measure the workload and choose an appropriate background-job or streaming design. Reuse or manage HttpClient through the application's normal lifetime management. The single client here lives for the duration of this console run.

The example also stops on a rate-limit response instead of repeatedly calling the API. A scheduled integration needs a retry policy that respects Retry-After, along with whatever monitoring the job requires.

The next time you check an export, try doing something with it. Sort the dates. Filter a numeric column. Read an identifier back and compare it with the source. Those checks tell you much more than whether Excel managed to open the file.

I Was Asked for Three Books. I Couldn't Do It.

Someone asked me recently for the three books, besides the Bible, that taught me the most about defending my faith. I could not do it. Every time I got the list down to three, I had to cut one that changed how I think, so I stopped narrowing and kept them all.

  • Mere Christianity by C.S. Lewis is where I would start with someone exploring Christianity. Lewis begins with ordinary human experience, including our sense of right and wrong, and works toward the Christian understanding of God and how we should live.

  • More Than a Carpenter by Josh and Sean McDowell is a shorter introduction, focused on Jesus and the evidence for his identity and resurrection. It is small enough to read in an afternoon and hand to a friend.

  • I Don't Have Enough Faith to Be an Atheist by Norman Geisler and Frank Turek makes a broader case, working through questions about truth, God's existence, and the evidence for Christianity. I value the way it develops the argument in steps, rather than treating each objection as an isolated question.

  • The Case for Christ by Lee Strobel approaches the evidence through interviews with scholars. Strobel asks about the reliability of the Gospel accounts and the case for the resurrection, then gives the scholars room to explain their answers.

  • Cold-Case Christianity by J. Warner Wallace examines the Gospel accounts using the methods he brought to his work as a cold-case homicide detective. Questions about witnesses, corroboration, and the handling of evidence give it a different approach from the other books here.

  • Tactics by Greg Koukl is the most practical book on this list for having a conversation about faith. It helps with asking questions, understanding what someone means, and examining a claim without turning the discussion into a fight. Knowing an argument does not necessarily mean knowing how to bring it into a conversation.

  • Love Your God with All Your Mind by J.P. Moreland makes the case that developing the mind is part of Christian discipleship. Reasoning carefully about what I believe belongs in my life as a Christian, including when no one is asking me to defend it.

  • The Story of Reality by Greg Koukl presents Christianity as an account of the world, from its beginning through its final resolution. It helps explain how the beliefs fit together, which can get lost when a conversation moves from one objection to the next.

  • Seven Days That Divide the World by John Lennox addresses Genesis and science, including different interpretations of the creation account. Science comes up more than any other subject when people find out I take Christianity seriously, and I appreciate Lennox's care with both the biblical text and the scientific questions.

  • Can Science Explain Everything? by John Lennox is a shorter book on the relationship between science and Christian belief. It examines what science can explain and whether those explanations leave any reason to believe in God. I would suggest this one for someone who wants to begin with that question rather than the interpretation of Genesis.

If I had to give someone just three to begin with, I would choose Mere Christianity for an introduction to the faith, I Don't Have Enough Faith to Be an Atheist for a fuller examination of the arguments, and Tactics for someone who already believes but goes quiet whenever the subject comes up.

Thirty Years of Code, Five Lessons I Keep Using

I started writing software in the mid-1990s. I've worked through changes in languages, frameworks, and database versions ever since. Looking back, five lessons stand out in the way I work now. Some took much longer to learn than others.

  1. Know what actually runs. When I write a query, I want to see its execution plan and understand which indexes it uses. If it is slow, I want to investigate the statement the database received. That is a large part of why I still use Dapper. I'm comfortable working directly in SQL, and I want to be able to follow a query from the application into the database when I need to diagnose a problem.

  2. The data outlives the code. This took me longer to fully respect. I recently moved an application first written in 2013 onto current .NET, and the production database under it did not change at all. The schema I had designed years earlier was still doing its job after the original application was replaced. Database design deserves care on its own terms, because the application being built today may be only one of several that eventually use it.

  3. Test the way the application actually runs. Before changing a piece of authentication logic recently, I ran a read-only check across thousands of real accounts, connecting the way the real application connects. The connection path was part of what I needed to verify. A simplified test could exercise the logic and still miss a problem caused by how the application accessed those accounts. For that change, I needed evidence from the conditions it would actually encounter.

  4. Understanding the business often takes more work than writing the code. Early on, I expected the difficult part to be the implementation. Over time, I became more concerned with understanding what the business needed and keeping those rules clear in the application. When a rule is scattered across database logic and screen behavior, even a small change can require looking in several places to understand what it will affect. I want to be able to find the rule, explain it, and change it without having to rediscover how the business works.

  5. Keep reviewing my own decisions. During a redesign of My New Password, I found a security bug in the password generator I had written years earlier. I used the site frequently myself, and the passwords looked as random as I expected. It took looking at the implementation again to see the problem. I want to give code I wrote years ago the same attention I would give a change I was reviewing today.

One Verse I Keep Coming Back To

Paul says he learned to be content. That word, learned, is the one I keep coming back to. I find it encouraging that he describes something he had to learn, because contentment can seem like a quality some people simply have and others don't.

He wrote the letter while imprisoned. In the first chapter, he considers both the possibility of death and the work he could continue if he lived. He wants to be with Christ, but he also wants to remain and help the people he is writing to.

And notice it runs both directions. Paul says he knows how to live with little, and he knows how to live with plenty (Philippians 4:12). I tend to think of contentment as something needed when life is hard. Having enough sounds as though it ought to settle the question. But it is possible to have plenty and still be dissatisfied, or to become so comfortable that dependence on God gets little thought.

That is the setting for a verse that often gets separated from the sentences around it:

I can do all things through him who strengthens me. (Philippians 4:13, ESV)

It is easy to read that as encouragement to accomplish whatever we set our minds to. Read it after Paul's description of hunger and plenty, though, and the claim is more specific. He depends on Christ for the strength to live through either. His confidence does not depend on getting the outcome he wants. That makes the verse useful even when a situation fails to improve.

Paul also thanks the Philippians for helping him. Immediately after verse 13, he acknowledges their kindness in sharing his trouble, and a few verses later he says their gifts have supplied his needs. He can be content and still appreciate relief. There is no need to pretend hunger is harmless or that another person's help makes no difference.

I keep coming back to this passage because most days I am nearer the plenty than the prison, and that is exactly when I forget. I can read it as encouragement for someone going through a hard time and miss how much it has to do with me. Paul includes plenty among the circumstances in which he relies on Christ. I need that dependence when life is comfortable too.

Qualys 150300: HTTP Request Smuggling on Azure App Service

Qualys recently started reporting 150300: HTTP Request Smuggling against a .NET web application I work on in Azure App Service. Earlier scans had not flagged it. We tested two configuration changes independently, and either one cleared the finding in our tests: an IIS request-filtering rule in web.config, or enabling HTTP/2 in App Service. I ultimately standardized on HTTP/2.

The changes were small, but understanding what they did mattered. A newly reported finding does not establish when the underlying condition began. Qualys has had this QID since 2020. Without comparing the scan evidence and configuration history, I would not attribute its appearance to a particular scanner or platform update.

HTTP request smuggling happens when components handling a request disagree about where it ends. A reverse proxy might treat some bytes as part of one request while the server behind it treats those same bytes as the beginning of another. Depending on the system, that disagreement can let an attacker bypass request checks or interfere with another user's traffic.

In the CL:TE test Qualys documents for QID 150300, the front end follows Content-Length, while the back end follows Transfer-Encoding. The former specifies the body length in bytes; Transfer-Encoding: chunked describes a body sent in chunks. The problem is inconsistent handling of request boundaries across the connection.

Here are the two approaches we tested, and the tradeoffs I would consider before using either one elsewhere.

  1. The first approach used IIS request filtering in web.config. This is the relevant configuration, consolidated into one requestFiltering element:

    <configuration>
      <system.webServer>
        <security>
          <requestFiltering removeServerHeader="true">
            <requestLimits>
              <headerLimits>
                <add header="Transfer-Encoding" sizeLimit="0" />
              </headerLimits>
            </requestLimits>
            <verbs>
              <add verb="TRACE" allowed="false" />
            </verbs>
          </requestFiltering>
        </security>
      </system.webServer>
    </configuration>
    

    The setting relevant to request framing is sizeLimit="0" on Transfer-Encoding. Microsoft's header-limit documentation says that a zero limit effectively denies requests containing that header. It does not remove the header and let the request continue.

    The other two settings do different jobs. removeServerHeader="true" suppresses IIS's server response header, and the verbs entry denies TRACE requests. Those are separate IIS filtering settings; neither resolves disagreement over request lengths. Merge these entries into the existing configuration rather than replacing the whole file or adding a second requestFiltering element. Update any existing matching collection entries instead of duplicating them.

    The advantage is an explicit rule that can live alongside the application in source control. The cost is that it also rejects legitimate requests using chunked transfer encoding if they reach IIS with that header. Uploads and streaming clients need particular attention. This is an IIS-specific restriction, and it acts where IIS applies the filter; it does not establish that every upstream proxy handles requests correctly.

  2. The second approach was enabling HTTP/2 in Azure App Service. This independently cleared the finding in our tests and was the option I chose to standardize on. Microsoft's configuration documentation places the HTTP version setting under Configuration, General settings. Set it to 2.0 and apply the change.

    There are good reasons to enable it beyond this finding. HTTP/2 allows multiple requests to share a connection concurrently and compresses headers to reduce overhead. PSRule for Azure recommends considering HTTP/2 for App Service protocol efficiency. It is a supported platform capability that does not require rewriting the application's handlers.

    The advantage here was a simple platform setting, without adding a blanket ban on the Transfer-Encoding header. Enabling support also leaves HTTP/1.1 available for clients that use it. For ordinary HTTPS applications, that makes HTTP/2 a reasonable default to test and adopt. Keep HTTPS enabled and verify the application's actual clients and integrations; HTTP/2 support and HTTPS enforcement are separate settings.

    The limitation is important: enabling HTTP/2 does not require every connection to use it. Microsoft's App Service protocol documentation distinguishes enabling HTTP/2 at the front end from configuring HTTP/2 proxying to the application. The version setting alone is not proof of HTTP/2 from the client through to the back end.

That distinction matters for request smuggling. HTTP/2 uses explicit framing, but a proxy can translate an incoming HTTP/2 request into HTTP/1.1 before forwarding it. PortSwigger documents how that translation can introduce request-smuggling vulnerabilities. Qualys's recommendation to use HTTP/2 for back-end connections is therefore more specific than turning on HTTP/2 support for clients.

Our A/B tests established that both changes independently resolved the reported finding in our environment. That is a useful result. To carry the same conclusion into another environment, I would check:

  • Retest QID 150300 against the same endpoint with comparable scan settings, and inspect the evidence rather than relying only on its absence from a summary.
  • Verify the negotiated protocol and test any remaining HTTP/1.1 path. A browser showing HTTP/2 does not tell you which protocol a proxy uses behind it.
  • Exercise normal application traffic, especially uploads, streaming requests, authentication, and integrations. Include the CDN, WAF, or other proxies that are actually in the request path.

I chose HTTP/2 because it resolved this finding in our tests and gave us a useful platform standard without the additional IIS restriction. I would make that setting part of deployment configuration so it is reproduced consistently, and keep the scan evidence with the change. That records both what we changed and what we verified.

What Billy Graham Taught Me About the Cross

I have listened to Billy Graham and read his books for a long time, especially over the past decade. His autobiography, Just As I Am, is one of my favorites. One story from it has stayed with me: the night a friend told him he had left the cross out of his sermon.

It happened in Dallas in 1953. Graham recalled feeling that his preaching that evening had lacked spiritual depth, even though people had responded to the invitation. Afterward, he took a walk with his friend John Bolten, a German-born businessman. Bolten told him what was missing.

“Billy,” he said, “you didn’t speak about the Cross.”

In the account from Just As I Am, Bolten pressed him to explain Christ's death to the people he was asking to believe. Graham spent a sleepless night thinking about that conversation. By morning, he had resolved to make the gospel clear in his preaching, with Christ's death for our sins and His resurrection at its center.

He also told the story in a 1959 address to ministers. There, he recalled praying:

“Oh, God, so help me, there will never be a sermon that I preach unless the Cross is central.”

I admire his willingness to receive that correction. He took it seriously enough to change his preaching and to include the failure in his own autobiography. Reading it years later, I am grateful that he did. It gives the rest of us something more useful than an account of a successful evening.

It is possible to say many true things about Christianity and still leave someone unclear about why Jesus died. We can talk about living well, finding purpose, or having hope. We can defend the reliability of Scripture and answer objections to belief. But the person listening still needs to hear what God has done for sinners, and why we need Him to do it.

That part of the story really hits home for me. I care about apologetics. I read the arguments, think about the objections, and want to give honest answers. Those questions deserve attention. I also want anyone who reads what I write about faith to understand what I am inviting them to believe. Christianity rests on Jesus Christ, who died for our sins and rose from the dead. He calls us to turn from sin and trust Him for forgiveness. We cannot earn it by becoming better at defending Christianity, or by becoming impressive Christians ourselves.

In 1 Corinthians 2:2 (ESV), Paul put his purpose plainly: “For I decided to know nothing among you except Jesus Christ and him crucified.” Later in the same letter, he reminded the Corinthians of Christ's death for their sins, His burial, and His resurrection (1 Corinthians 15:3-4). That was the good news Graham's friend was urging him to make clear.

For me, there is comfort in that as well as a responsibility. The message does not depend on my having Billy Graham's voice or his ability to speak to a crowd. I can explain what Christ has done, listen to someone's questions, and trust God with what I cannot accomplish in another person's heart.

I am thankful for Billy Graham and for the friend who spoke honestly to him that night. The reason the story stays with me is that I need the same Savior he preached. Whatever opportunity I have to speak about my faith, I want to make clear why my hope is in Christ.

Claude Is Losing My Work to ChatGPT

I left OpenAI's ChatGPT in March 2025 and moved to Anthropic's Claude full time. I did not expect to come back. Eighteen months later, I pay for both, and an increasing share of my work is going to ChatGPT.

That was not the outcome I was rooting for.

I had used ChatGPT since March 2024. Concerns about data control and privacy were part of why I left, along with unease about news surrounding OpenAI's leadership. I was more comfortable putting my money and time into Anthropic. For a long while, that felt like the right decision.

Since August 2026, I have been using ChatGPT heavily again. I have been comparing the two on work I actually need done: writing and editing, generating and reviewing code, improving processes, and analyzing data. I am a full-stack developer with more than thirty years in software. These tools have to earn their place in that work.

The redesign of My Family Devotion made the difference particularly clear. I wanted to improve the site's user interface, user experience, and SEO. In my comparison, the ChatGPT workflow produced:

  • A more polished design that adapted better to different screen sizes and worked better on mobile.
  • Cleaner HTML and code, with far fewer rounds of correction to reach a higher-quality deliverable.
  • Better graphics, including higher-resolution assets, along with better logo, icon, and typography choices.
  • Faster completion and more work accomplished within the usage allowance.

Astra also spotted that the individual devotions were missing unique page titles and descriptions. That gave me a specific SEO issue to address alongside the visual redesign. Any resulting change in search traffic or rankings would need to be measured separately.

That project helps explain why more of my work has been moving back. I still use Claude, and the preference varies by task. But on this redesign, ChatGPT gave me a better result with less back-and-forth. Those are differences that matter when I am responsible for finishing and maintaining the application.

My subscriptions are Claude Max 5x and ChatGPT Pro 5x. As of September 2026, both have a published US web subscription price of $100 a month. The matching 5x labels refer to each company's own baseline; they do not promise equal capacity. What matters to me is how much useful work each subscription buys.

A model can produce an excellent first answer and still be expensive to work with. The answer might need revision, the code needs testing, and the analysis needs checking. I need enough usable capacity to get through all of that. Access to an impressive model is worth less if I cannot afford to finish the work with it.

Token use is a large part of this. Tokens are the units used to represent the content models process and generate. The short question I type is only part of what an AI system may process during a task. Conversation history, files, instructions, tool results, and reasoning can all contribute to usage. Three details matter particularly in longer sessions:

  • Context management determines what information is carried into the next step. Loading a whole repository when a few files would do can consume capacity without helping the answer.
  • Prompt caching reuses processing of unchanged input. Both OpenAI and Anthropic document API caching that can reduce repeated-input costs. It is not the same as retrieving an old answer, and its availability does not guarantee that a particular request benefits from it.
  • Compaction reduces the context carried forward so a long task can continue. OpenAI describes it as balancing quality, cost, and latency. From my side of the conversation, it also needs to preserve the decisions and constraints that matter. If I have to reconstruct them afterward, that adds work.

There is an important billing distinction here. API charges and a subscription's usage allowance are different things. I cannot take an advertised API cache discount and assume it gives me the same increase in subscription capacity. Anthropic's usage documentation explains that long conversations, model choice, and features affect the allowance. OpenAI's documentation for Codex and ChatGPT Work also describes usage that varies with task complexity, context, tools, and caching.

Both companies have optimization mechanisms. My side-by-side results do not reveal which internal mechanism explains the difference I experience. They do tell me where I currently get more useful work for my money.

I would consider a lower-ranked model if it gave me substantially more room to work and still met the standard the task required. Extra attempts are useful only if they lead to a result I can verify; reviewing ten bad patches is expensive too. Sometimes paying more for a model that gets a difficult job right sooner is the economical choice. The comparison has to include the whole task, including my time.

Cost has not displaced the concerns that made me leave ChatGPT. I still want clear answers about what gets retained, what can be used for training, and what control I have over either. I need to understand the terms of the particular product and account I am using. A company's reputation cannot answer those questions for me.

The same goes for safety features. When an agent can access files, run commands, or connect to another service, I want meaningful control over those permissions and a clear record of what it did. Transparency also means explaining limits and changes well enough that I can plan around them.

Reliability belongs in the cost calculation as well. An outage during the time I have set aside to work, an interrupted task, or a limit I cannot reasonably anticipate can make a capable tool difficult to depend on. I want to evaluate that alongside the quality of its answers.

Lately, my experience as an Anthropic customer has left me feeling that my business matters less than I thought it did. That is a judgment about the experience I am paying for, not a claim to know anyone's motives. ChatGPT, to my surprise, has been earning more of my work. I am willing to acknowledge that even though I once thought I was finished with it.

I am still paying both companies. Anthropic can earn that work back, and OpenAI can lose it. I have spent too long in this industry to keep choosing a tool because it is the one I defended last year.