This guide shows you how to take a spreadsheet of social posts and publish them on a schedule with Python in under thirty minutes — reading a CSV of text, datetime, and platform, cleaning the copy with an LLM (large language model, the kind of AI behind ChatGPT) where it needs work, and firing each post at its planned time through an API. It is a practical building block for Automated Social Media Posting that you own end to end, with no monthly SaaS bill.
If you already know how to send one post — for example from Schedule Instagram Posts Using Python and AI — this guide is the next step up: running dozens of posts from a single file on a timer.
Prerequisites
You need Python 3.10+ and the four libraries below. Everything else is in the standard library.
pip install httpx openai apscheduler python-dotenv
Store your credentials in a .env file so they never end up in your code or your git history:
OPENAI_API_KEY=sk-...
SOCIAL_API_TOKEN=your_platform_token
SOCIAL_API_URL=https://api.example-social.com/v1/posts
Add .env to your .gitignore right away so these secrets are never committed.
Finally, prepare a posts.csv file. Three columns are required: the post text, the datetime to publish (ISO 8601, like 2026-06-20T14:30:00), and the target platform.
id,text,publish_at,platform,needs_cleanup
1,new blog post is live go read it,2026-06-20T09:00:00,twitter,true
2,Our Q3 webinar is open for sign-ups. Save your seat.,2026-06-20T15:00:00,linkedin,false
3,behind the scenes pics from todays shoot,2026-06-21T18:30:00,twitter,true
The needs_cleanup column lets you mark which rows the LLM should rewrite, so polished copy is left alone and you only pay for the rows that need help.
Step 1: Read and validate the CSV
Never trust a spreadsheet. A single bad date or empty cell will crash a naive loop halfway through a batch, leaving some posts sent and others not. Read every row into a typed object first, validate it, and collect the problems in one place.
import csv
from dataclasses import dataclass
from datetime import datetime
@dataclass
class Post:
id: str
text: str
publish_at: datetime
platform: str
needs_cleanup: bool
def load_posts(path: str) -> tuple[list[Post], list[str]]:
posts: list[Post] = []
errors: list[str] = []
with open(path, newline="", encoding="utf-8") as f:
for line_no, row in enumerate(csv.DictReader(f), start=2):
try:
post = Post(
id=row["id"].strip(),
text=row["text"].strip(),
publish_at=datetime.fromisoformat(row["publish_at"].strip()),
platform=row["platform"].strip().lower(),
needs_cleanup=row["needs_cleanup"].strip().lower() == "true",
)
if not post.text:
raise ValueError("text is empty")
posts.append(post)
except (KeyError, ValueError) as exc:
errors.append(f"line {line_no}: {exc}")
return posts, errors
if __name__ == "__main__":
posts, errors = load_posts("posts.csv")
print(f"Loaded {len(posts)} valid posts, {len(errors)} skipped.")
for err in errors:
print(" ", err)
datetime.fromisoformat parses the ISO 8601 strings and raises ValueError on anything malformed, so bad dates are caught here rather than at send time. The function returns valid posts and a list of human-readable errors so you can fix the spreadsheet before scheduling anything.
Step 2: Clean or generate copy with an LLM
Rows marked needs_cleanup get sent to the OpenAI API for a quick rewrite — fixing capitalisation, tightening wording, and respecting each platform's length limits. Rows that are already polished are left exactly as written, which keeps your token cost down. For deeper copy work, see AI Copywriting Workflows.
import os
from openai import OpenAI
from dotenv import load_dotenv
load_dotenv()
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
CHAR_LIMITS = {"twitter": 280, "linkedin": 3000, "mastodon": 500}
def clean_copy(text: str, platform: str) -> str:
limit = CHAR_LIMITS.get(platform, 280)
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": (
"You polish social media copy. Fix grammar and capitalisation, "
"keep the original meaning and any links, and never exceed the "
"character limit. Return only the rewritten post, no quotes."
),
},
{
"role": "user",
"content": f"Platform: {platform} (max {limit} chars).\nPost: {text}",
},
],
temperature=0.4,
)
return response.choices[0].message.content.strip()[:limit]
def apply_cleanup(posts: list[Post]) -> None:
for post in posts:
if post.needs_cleanup:
post.text = clean_copy(post.text, post.platform)
A system prompt sets the rewriting rules once, the user message carries the post and its limit, and a low temperature of 0.4 keeps results consistent rather than creative. The final [:limit] slice is a hard backstop in case the model ignores its instructions. To learn how these prompts work, read Write System Prompts that Control Output Format.
Step 3: Build a posting client with httpx
Each post goes out through one small function. Using httpx gives you a clean timeout and an easy path to async later, and it routes by the platform field so one CSV can feed several networks. Mark each post as sent so a re-run never double-posts.
import json
from pathlib import Path
import httpx
API_URL = os.getenv("SOCIAL_API_URL")
API_TOKEN = os.getenv("SOCIAL_API_TOKEN")
SENT_FILE = Path("sent_ids.json")
def load_sent() -> set[str]:
if SENT_FILE.exists():
return set(json.loads(SENT_FILE.read_text()))
return set()
def mark_sent(post_id: str, sent: set[str]) -> None:
sent.add(post_id)
SENT_FILE.write_text(json.dumps(sorted(sent)))
def publish(post: Post, sent: set[str]) -> None:
if post.id in sent:
print(f"Skipping already-sent post {post.id}")
return
payload = {"text": post.text, "platform": post.platform}
headers = {"Authorization": f"Bearer {API_TOKEN}"}
with httpx.Client(timeout=15) as http:
resp = http.post(API_URL, json=payload, headers=headers)
resp.raise_for_status()
mark_sent(post.id, sent)
print(f"Published post {post.id} to {post.platform}")
The sent_ids.json file is your safety net: if the script crashes or you run it again, posts already delivered are skipped. raise_for_status() turns any failed HTTP response into an exception so a rejected post never gets marked sent. Swap the API_URL and payload shape to match your real platform; the structure stays the same.
Step 4: Schedule everything with APScheduler
Now connect the pieces. APScheduler (Advanced Python Scheduler) holds a job for each post and fires it at its publish_at time. A persistent job store written to SQLite means posts survive a restart, so a job due while the script was briefly down still runs.
from apscheduler.schedulers.blocking import BlockingScheduler
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
def schedule_all(path: str) -> None:
posts, errors = load_posts(path)
for err in errors:
print("Skipped:", err)
apply_cleanup(posts)
sent = load_sent()
scheduler = BlockingScheduler(
jobstores={"default": SQLAlchemyJobStore(url="sqlite:///jobs.sqlite")}
)
now = datetime.now()
for post in posts:
if post.publish_at <= now:
publish(post, sent) # already due, send immediately
continue
scheduler.add_job(
publish,
"date",
run_date=post.publish_at,
args=[post, sent],
id=f"post-{post.id}",
replace_existing=True,
misfire_grace_time=3600,
)
print(f"Scheduled {len(scheduler.get_jobs())} posts. Press Ctrl+C to stop.")
scheduler.start()
if __name__ == "__main__":
schedule_all("posts.csv")
The "date" trigger fires a job once at a fixed moment. misfire_grace_time=3600 tells APScheduler that a post fired up to an hour late is still acceptable rather than dropped — useful if the machine was asleep. replace_existing=True plus the stable id means re-running the script updates jobs instead of duplicating them. Posts already past their time are sent on the spot so a late start never silently swallows them.
Quick reference: key parameters
| Parameter | Type | Default | Effect |
|---|---|---|---|
run_date | datetime | required | Exact moment the "date" job fires the post |
misfire_grace_time | int (seconds) | 1 | How late a job may fire before it is skipped |
replace_existing | bool | False | If True, re-adding a job id updates instead of erroring |
timeout (httpx) | int/float | none | Seconds before an API request is abandoned |
temperature | float | 1.0 | Lower values make LLM rewrites more predictable |
Troubleshooting
- Posts fire instantly instead of at the scheduled time. Your
publish_atvalues parsed into times in the past, so the "already due" branch sends them. Check that the CSV uses future ISO 8601 datetimes and that your machine's clock and time zone are correct. ModuleNotFoundError: No module named 'sqlalchemy'. The SQLAlchemy job store needs SQLAlchemy installed. Runpip install sqlalchemy, or drop thejobstoresargument to use the default in-memory store while testing.httpx.HTTPStatusError: 401 Unauthorized. The platform token is missing or wrong. ConfirmSOCIAL_API_TOKENis loaded from.envand that theAuthorizationheader matches the scheme your API expects. See Fix the 401 Unauthorized Error in OpenAI Python for the same debugging pattern.- The LLM returns text longer than the limit. Models occasionally ignore length rules, which is why the
[:limit]slice exists as a hard cut. If truncation breaks sentences, lower the requested limit in the prompt by a small margin so the model leaves room.
When to use this vs. alternatives
- Self-hosted Python scheduler (this guide): Best when you want full control, a free pipeline, custom logic like LLM cleanup, and posting to niche or multiple platforms from one CSV. The trade-off is that you run and monitor the process yourself — keep it alive as a service or a cron-launched job, and watch the logs.
- A SaaS scheduler (Buffer, Hootsuite, Later): Best when you want a calendar UI for non-technical teammates, guaranteed uptime, and built-in analytics without maintaining anything. The trade-off is a monthly fee, fixed platform support, and little room for custom AI steps in the middle of the flow.
- A hybrid approach: Use this script to generate and clean copy in bulk, then export the polished rows to a SaaS tool's import format. You get AI-assisted writing plus a managed publishing layer, at the cost of moving a file between two systems.
Related guides
- Automated Social Media Posting — the main guide this page sits under.
- Schedule Instagram Posts Using Python and AI — the single-platform version with Meta's Graph API.
- Generate Twitter Threads with Python and AI — turn one idea into a multi-post thread before scheduling.
- AI Copywriting Workflows — deeper techniques for the copy-cleanup step.
Back to Automated Social Media Posting.