Content & Marketing

Generate Blog Posts with the OpenAI API

Build a Python script that turns one keyword into a full Markdown blog post with the OpenAI API: outline, draft, refine, and save to a file.

This guide shows you how to turn a single keyword into a full, edited-ready Markdown blog post in about fifteen minutes, using Python and the OpenAI API. You will build a small script that works in four stages: outline, draft, refine, and save. Splitting the work this way gives you far more reliable results than asking the model to "write a blog post" in one shot.

If you have never called an AI model from code before, the Understanding LLM APIs section explains the basics first. Otherwise, read on.

Prerequisites

You only need three things beyond a working Python 3.10 or newer install:

  • An OpenAI account and an API key (a secret string that authorizes your requests). Create one in the OpenAI dashboard under API keys.
  • A folder to work in, with a virtual environment so your packages stay isolated. If you have not made one before, follow Create a Python Virtual Environment for AI.
  • The two packages this script uses, installed into that environment:
pip install openai python-dotenv

The openai package is the official SDK (software development kit) that talks to the API. python-dotenv loads secrets from a file so you never paste your key directly into code.

Step 1: Set up your environment and key

Create a file called .env in your project folder and put your key inside it. The .env file holds secrets that should never appear in your code:

OPENAI_API_KEY=sk-your-real-key-goes-here

Now add .env to your .gitignore file so the key is never committed to version control or pushed to a public repository:

echo ".env" >> .gitignore

That one line prevents the most common way beginners accidentally leak a paid API key. Next, create a file called blog_writer.py and load the key into a client object. The client is the gateway you call for every request:

import os
from dotenv import load_dotenv
from openai import OpenAI

load_dotenv()  # reads the .env file into environment variables
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))

MODEL = "gpt-4o-mini"  # cheap and fast; swap for "gpt-4o" if you need stronger writing

If this line raises an authentication error, your key is missing or wrong. The Fix the 401 Unauthorized Error in OpenAI Python guide walks through every cause.

Nothing about that key is magic, and it helps to picture the short journey it makes. It sits in one file that git never sees, load_dotenv() copies it into your running program, the client object holds it, and the API checks it on every request you send.

How your OpenAI key travels from the .env file to the API A four-layer stack showing the API key moving from an untracked dot-env file, through load_dotenv, into the OpenAI client object, and finally onto every request the API verifies, with a side note showing the gitignore line that keeps the file out of version control. The .env file OPENAI_API_KEY=sk-... Your .gitignore keeps .env out of git load_dotenv() reads .env at startup The client object OpenAI(api_key=...) The OpenAI API verifies the key
The key exists in exactly one file on your machine; every layer above it borrows the value rather than storing a second copy, which is why the .gitignore line is the only safeguard you need.

Step 2: Generate an outline

A good article needs a skeleton before it needs prose. Asking the model for an outline first means the draft follows a deliberate structure instead of rambling. Add this helper function, which returns a clean list of headings:

def generate_outline(topic: str, audience: str) -> str:
    response = client.chat.completions.create(
        model=MODEL,
        messages=[
            {
                "role": "system",
                "content": (
                    "You are a content strategist. Reply with a blog post outline "
                    "as a Markdown list of H2 and H3 headings only. No prose."
                ),
            },
            {
                "role": "user",
                "content": (
                    f"Create an outline for a blog post about '{topic}' "
                    f"written for {audience}. Aim for 5 to 7 main sections."
                ),
            },
        ],
        temperature=0.6,
    )
    return response.choices[0].message.content

The system message sets the role and the rules; the user message carries your specific request. A slightly lower temperature (0.6) keeps the outline focused. For more on writing instructions that the model actually obeys, see Write System Prompts that Control Output Format.

Handing one call's output to the next call as input has a name — prompt chaining — and it is the single technique that separates usable output from slot-machine output. You will meet it again in almost every script you write, so if you want the general pattern rather than this one application of it, Chain Prompts Together in Python works through it from scratch.

Step 3: Draft the full post

Now feed the outline back to the model and ask it to expand each heading into a finished article. Passing the outline as context is what keeps the draft on track:

def generate_draft(topic: str, audience: str, outline: str) -> str:
    response = client.chat.completions.create(
        model=MODEL,
        messages=[
            {
                "role": "system",
                "content": (
                    "You are an expert copywriter. Write in clear, plain English. "
                    "Output valid Markdown using ## and ### headings. "
                    "Start with a one-paragraph introduction and no title."
                ),
            },
            {
                "role": "user",
                "content": (
                    f"Write a 1,200-word blog post about '{topic}' for {audience}. "
                    f"Follow this outline exactly:\n\n{outline}\n\n"
                    "End with three actionable takeaways as a bullet list."
                ),
            },
        ],
        temperature=0.7,
        max_tokens=2500,
    )
    return response.choices[0].message.content

Here temperature=0.7 gives the prose a little personality without going off the rails, and max_tokens=2500 leaves enough room for a long article. One token is roughly three-quarters of a word, so 2500 tokens covers a 1,200-word post comfortably. If your draft cuts off mid-sentence, raise max_tokens. If the temperature setting itself is still fuzzy, What Is Temperature in an LLM API? explains the dial and shows what the same prompt produces at each end of it.

With two of the four stages written, the shape of the finished run is worth seeing on one screen. Every box below is a separate API call except the last one, which only touches your disk, and the arrow feeding the draft call is the outline you generated a moment ago.

The four-stage path from one topic to a saved Markdown file A data-flow diagram: the topic and audience feed an outline call, the outline feeds a draft call, the draft feeds a refine call, and the refined text is written to a Markdown file on disk. Topic + audience what you type in Outline call 5 to 7 headings Draft call outline as context Refine call the editor pass Save to disk output/slug.md
Three API calls and one file write: each stage hands its whole output to the next, which is why a weak outline shows up as a weak article three steps later.

Step 4: Refine the draft

First drafts from any model tend to open with a generic sentence and pad the middle. A cheap second pass fixes that. This function asks the model to edit its own work, which is more effective than trying to get a perfect draft in one request:

def refine_draft(draft: str) -> str:
    response = client.chat.completions.create(
        model=MODEL,
        messages=[
            {
                "role": "system",
                "content": (
                    "You are a ruthless editor. Tighten the writing, cut filler "
                    "and repetition, and rewrite a weak opening line. "
                    "Keep all Markdown headings. Return the full edited post."
                ),
            },
            {"role": "user", "content": draft},
        ],
        temperature=0.4,
    )
    return response.choices[0].message.content

The low temperature=0.4 keeps the editor conservative so it tightens the text instead of rewriting your meaning. This refine step is the single biggest quality lever in the whole script.

Three faults show up in almost every first draft, and the editor pass fixes all three for the price of one extra call. Knowing which three helps you judge whether a run worked before you read the whole file.

What the refine pass changes in a first draft A before-and-after comparison with three rows: a generic opening becomes a specific one, a padded middle becomes tighter, and a wandering tone becomes a steady one. Raw draft After refining Generic opening throat-clearing intro Specific opening leads with the point Padded middle repeats each heading Tighter middle repetition removed Wandering tone shifts register Steady tone one voice throughout
The refine pass is an editor, not a rewriter: it attacks openings, padding, and tone drift while leaving your headings and argument where the outline put them.

Step 5: Save the post to a file

Finally, write the finished Markdown to disk with a clean filename derived from the topic. Validating that the response is not empty protects you from saving a blank file after a failed call:

from pathlib import Path


def save_post(text: str, topic: str) -> Path:
    if not text.strip():
        raise ValueError("Empty response from the API; nothing to save.")
    slug = topic.lower().strip().replace(" ", "-")
    Path("output").mkdir(exist_ok=True)
    path = Path(f"output/{slug}.md")
    path.write_text(text, encoding="utf-8")
    return path

Now wire the four stages together at the bottom of the file and run it:

if __name__ == "__main__":
    topic = "Python automation for marketers"
    audience = "non-technical marketing managers"

    outline = generate_outline(topic, audience)
    draft = generate_draft(topic, audience, outline)
    final = refine_draft(draft)
    saved_to = save_post(final, topic)
    print(f"Saved post to {saved_to}")

Run it from your terminal:

python blog_writer.py

You will find a polished Markdown file inside the output folder, ready for you to read, fact-check, and publish.

Key parameter quick reference

These are the settings you will adjust most often. Tune the temperature per stage rather than using one value everywhere.

ParameterTypeDefault hereEffect
modelstringgpt-4o-miniPicks the engine. gpt-4o-mini is cheapest; gpt-4o writes better but costs more.
temperaturefloat0.40.7Controls randomness. Lower means consistent and safe; higher means creative and varied.
max_tokensint2500Caps the length of the reply. Raise it if drafts get cut off; lower it to save money.

Remember that one finished post is three billed calls, not one, and the draft call is by far the largest of the three because it carries the outline in and a full article out. That is still small money for a single post, but it multiplies the moment you loop over a keyword list. Before you scale a run up, Estimate OpenAI API Costs with Python shows how to turn the token counts every response already reports into a real figure, and how to check it against the provider's current pricing page rather than a number from a blog post.

Troubleshooting

  1. The draft is much shorter than 1,200 words. Models treat word counts as a loose target. Generating section by section helps, but the reliable fix is to raise max_tokens and to ask explicitly for "at least 1,200 words" in the prompt.
  2. RateLimitError or a 429 message. You sent requests too fast or hit your spending cap. Add a short pause between calls, or follow Fix the 429 Rate-Limit Error in Python to add automatic retries.
  3. **The output is wrapped in a markdown code fence.** Some models wrap the whole reply in a fence. Strip it before saving with `text.strip().removeprefix("markdown").removesuffix("```").strip()`.
  4. The post sounds generic and bland. That is almost always a weak prompt, not a weak model. Give the model a specific audience, a point of view, and concrete examples to include. The Prompt Engineering Templates for Marketers guide has ready-made starting points.
  5. The refine pass deleted or renamed your headings. The editor prompt was too free. Add "Do not add, remove, reorder or reword any heading" to the system message, and lower temperature to 0.2 for that call only. If it keeps happening, refine one section at a time instead of the whole post.

When to use this vs. alternatives

  • Use this script when you write one article at a time, want full control over structure and tone, and plan to edit before publishing. The outline-draft-refine flow gives the best quality for a single piece.
  • Reach for a batch approach when you need dozens of short, similar pieces, such as rewriting a catalog. Bulk-Rewrite Product Descriptions with Python loops over a list instead of building one long article.
  • Pick a different format when your output is not a long-form post. For a recurring email, Generate Email Newsletters with Python and AI uses a shorter, section-based template that fits an inbox better.
  • Start from a recording when the raw material already exists as audio. Turn a Podcast Episode into a Blog Post transcribes first and then runs a staged rewrite much like this one, so you are editing rather than inventing.

Read as a single question, the choice comes down to what you are actually producing and how many of them you need.

Which copywriting script to reach for A decision tree: one long article leads to this outline-draft-refine script, many short items lead to the bulk rewrite guide, and a recurring email leads to the newsletter guide. What do you need? pick one branch One long article 1,000 words or more Many short items a whole catalogue A recurring email sent every week This script outline, draft, refine Bulk-rewrite guide loops over a list Newsletter guide short sections
Length and repetition decide the tool: one long piece justifies three staged calls, while dozens of short pieces want a loop and a recurring email wants a fixed template.

Once this works, fold it into your broader AI Copywriting Workflows so a keyword list flows straight into finished drafts.

Back to AI Copywriting Workflows.

Frequently asked questions

Which OpenAI model should I use to write blog posts?

Start with gpt-4o-mini. It is cheap, fast, and good enough for first drafts you will edit anyway. Switch to gpt-4o only if you need stronger reasoning or longer, more nuanced articles.

How long should each blog post be?

Ask for 1,000 to 1,500 words in the prompt. The model treats word counts as a target, not a guarantee, so you will usually land within a few hundred words. Build the post in sections to get more reliable length.

Will Google penalize AI-generated blog posts?

Google ranks helpful content regardless of how it was produced, but it penalizes thin, unedited spam. Always read, fact-check, and edit the draft before publishing. Treat the script as a writing assistant, not an autopilot.

How much does it cost to generate a blog post with the OpenAI API?

With gpt-4o-mini, a single 1,200-word post usually costs well under one US cent. Costs scale with the number of tokens in and out, so longer prompts and longer articles cost a little more.

Do I need to know how to code to run this script?

You need to install Python, paste the script, and add your API key. You do not need to write code from scratch. Each step below is copy-paste ready and explained in plain language.