Before we dive in: I share practical insights like this weekly. Join developers and founders getting my newsletter with real solutions to engineering and business challenges.
I found myself staring at logs at 2am last week, trying to figure out why our data cleanup jobs hadn't run in three days. Everything looked fine - the scheduler was running, the code was correct, but nothing was happening. Turns out a third-party API had quietly switched their datetime format from RFC3339 to some custom layout, and our time parsing was failing silently. The cleanup tasks never kicked off, and we had weeks of data piling up that should have been deleted.
This is the kind of thing that drives you mad. You write a scheduler, test it thoroughly, deploy to production, and it works perfectly for months. Then one day an upstream service changes their date format and everything breaks in the most subtle way possible.
I've built schedulers for everything - token limit resets, data cleanup, API health checks, billing calculations. I've implemented rate limiters using time windows, added request timing to HTTP handlers, and created database timeout patterns that actually work when things go wrong. The Go time package is incredibly powerful, but it's also surprisingly easy to get wrong.
The problem isn't the documentation - it's comprehensive. The problem is that most examples show you the basics, not the production patterns that matter. How do you handle schedulers across multiple servers? What happens when your database queries timeout? How do you build a rate limiter that doesn't fall over under load?
Here's what I've learned about working with time in Go, from parsing dates correctly through to building schedulers that run reliably in production. We'll cover the fundamentals, then dive into real examples from systems I've built - data retention schedulers, database monitoring, rate limiting, and the gotchas that will save you hours of debugging.
Go Time Package Fundamentals
The Go time package revolves around three core types that you'll use constantly: time.Time
, time.Duration
, and timers. Once you understand these properly, everything else becomes much clearer.
Working with time.Time
time.Time
represents an instant in time. The key thing to understand is that it's always stored in UTC internally, but it carries timezone information. This is brilliant for avoiding timezone bugs, but it can catch you off guard.
now := time.Now()
fmt.Println(now) // 2024-01-15 14:30:45.123456789 +0000 UTC
// Creating specific times
birthday := time.Date(1990, time.March, 15, 0, 0, 0, 0, time.UTC)
The most common mistake I see is assuming time.Now()
gives you local time. It doesn't - it gives you the current time in the system's timezone. If you're running in a Docker container or on a server in a different timezone, this can lead to confusion.
Duration handling that actually works
time.Duration
represents a span of time. It's an int64 under the hood, counting nanoseconds. This makes arithmetic operations fast and precise.
// Common durations
fiveMinutes := 5 * time.Minute
halfHour := 30 * time.Minute
oneDay := 24 * time.Hour
// Duration arithmetic
total := fiveMinutes + halfHour
fmt.Println(total) // 35m0s
// Converting to different units
seconds := total.Seconds() // 2100.0
milliseconds := total.Milliseconds() // 2100000
One thing that caught me out early on - you can't multiply durations by floats directly. You have to convert:
// This won't compile
// result := 2.5 * time.Hour
// This works
result := time.Duration(2.5 * float64(time.Hour))
Timers and tickers for scheduling
time.Timer
and time.Ticker
are your tools for scheduling. Timers fire once, tickers fire repeatedly.
// Timer - fires once after 5 minutes
timer := time.NewTimer(5 * time.Minute)
<-timer.C // blocks until timer fires
// Ticker - fires every 30 seconds
ticker := time.NewTicker(30 * time.Second)
for range ticker.C {
// This runs every 30 seconds
doPeriodicTask()
}
The critical thing with timers and tickers is cleanup. If you don't stop them properly, you'll leak goroutines:
ticker := time.NewTicker(time.Second)
defer ticker.Stop() // Always stop tickers
// For timers, you might want to stop early
timer := time.NewTimer(5 * time.Minute)
defer timer.Stop()
select {
case <-timer.C:
// Timer fired normally
case <-ctx.Done():
// Context cancelled, timer.Stop() called by defer
}
Time Formatting and Parsing in Go
This is probably where most time-related bugs happen. Go's approach to time formatting is unique and surprisingly powerful, but it's also where that 2am debugging session I mentioned earlier came from.
Go's reference time approach
Instead of format codes like %Y-%m-%d
, Go uses a reference time: Mon Jan 2 15:04:05 MST 2006
. This is Unix time 1136239445
, and you format by showing how you want this reference time displayed.
now := time.Now()
// Common formats
fmt.Println(now.Format("2006-01-02 15:04:05")) // 2024-01-15 14:30:45
fmt.Println(now.Format("02/01/2006")) // 15/01/2024
fmt.Println(now.Format(time.RFC3339)) // 2024-01-15T14:30:45Z
Advanced golang time formatting patterns
Beyond the basic formats, there are some patterns I use regularly in production systems:
now := time.Now()
// API timestamps - RFC3339 is the standard
apiTime := now.Format(time.RFC3339) // 2024-01-15T14:30:45Z
// Log formatting - readable but sortable
logTime := now.Format("2006-01-02 15:04:05") // 2024-01-15 14:30:45
// File naming - no special characters
fileTime := now.Format("20060102-150405") // 20240115-143045
// User display - depends on audience
userTime := now.Format("02 Jan 2006 at 15:04") // 15 Jan 2024 at 14:30
The key is consistency - pick a format for each use case and stick with it across your entire system.
Parsing datetime strings reliably
Parsing is where things get dangerous. The format you use for parsing must match the input exactly:
// This works
input := "2024-01-15 14:30:45"
parsed, err := time.Parse("2006-01-02 15:04:05", input)
if err != nil {
log.Fatal(err)
}
// This fails - format doesn't match
input2 := "15/01/2024"
parsed2, err := time.Parse("2006-01-02 15:04:05", input2)
// err: parsing time "15/01/2024": month out of range
Here's the pattern I use for handling multiple possible formats:
func parseFlexibleTime(input string) (time.Time, error) {
formats := []string{
time.RFC3339,
"2006-01-02 15:04:05",
"2006-01-02T15:04:05",
"02/01/2006 15:04:05",
"2006-01-02",
}
for _, format := range formats {
if t, err := time.Parse(format, input); err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("unable to parse time: %s", input)
}
Timezone handling that won't bite you
Timezones are where most production bugs happen. The key insight is that time.Time
carries timezone information, but comparison and arithmetic operations work correctly regardless.
// Same instant, different timezones
utc := time.Date(2024, 1, 15, 14, 30, 0, 0, time.UTC)
london, _ := time.LoadLocation("Europe/London")
local := utc.In(london)
fmt.Println(utc) // 2024-01-15 14:30:00 +0000 UTC
fmt.Println(local) // 2024-01-15 14:30:00 +0000 GMT
// These are equal - same instant
fmt.Println(utc.Equal(local)) // true
When parsing times from external sources, always be explicit about timezone:
// Dangerous - assumes local timezone
parsed, _ := time.Parse("2006-01-02 15:04:05", "2024-01-15 14:30:45")
// Better - explicit timezone
parsed, _ := time.ParseInLocation("2006-01-02 15:04:05", "2024-01-15 14:30:45", time.UTC)
Building Production Schedulers
I've built more schedulers than I care to count - data cleanup, token resets, health checks, billing calculations. Here's what actually works in production.
Multi-tier data retention scheduler
Let's build a practical example - a data cleanup system that handles different retention periods based on subscription tiers. Free users get 3 days of data, Basic gets 7 days, Pro gets 30 days.
type RetentionScheduler struct {
db *sql.DB
ticker *time.Ticker
done chan bool
}
type RetentionPolicy struct {
Tier string
Days int
}
func NewRetentionScheduler(db *sql.DB) *RetentionScheduler {
return &RetentionScheduler{
db: db,
done: make(chan bool),
}
}
func (rs *RetentionScheduler) Start() {
// Run every hour
rs.ticker = time.NewTicker(time.Hour)
go func() {
for {
select {
case <-rs.ticker.C:
rs.cleanupExpiredData()
case <-rs.done:
return
}
}
}()
}
func (rs *RetentionScheduler) Stop() {
rs.ticker.Stop()
rs.done <- true
}
The cleanup logic needs to handle different retention periods efficiently:
func (rs *RetentionScheduler) cleanupExpiredData() {
policies := []RetentionPolicy{
{"free", 3},
{"basic", 7},
{"pro", 30},
}
for _, policy := range policies {
cutoff := time.Now().AddDate(0, 0, -policy.Days)
result, err := rs.db.Exec(`
DELETE FROM user_data
WHERE subscription_tier = $1
AND created_at < $2`,
policy.Tier, cutoff)
if err != nil {
log.Printf("Error cleaning up %s tier data: %v", policy.Tier, err)
continue
}
rowsAffected, _ := result.RowsAffected()
log.Printf("Cleaned up %d rows for %s tier (older than %s)",
rowsAffected, policy.Tier, cutoff.Format("2006-01-02"))
}
}
Handling scheduler synchronisation
The tricky bit with schedulers is making sure they run at the right time across multiple servers. You don't want every instance running cleanup at slightly different times, creating database load spikes.
Here's the pattern I use to synchronise scheduler start times:
func (rs *RetentionScheduler) StartAtNextHour() {
now := time.Now()
nextHour := now.Truncate(time.Hour).Add(time.Hour)
// Wait until the next hour boundary
time.Sleep(time.Until(nextHour))
// Now start the regular ticker
rs.Start()
log.Printf("Retention scheduler started at %s", nextHour.Format("15:04:05"))
}
This ensures all your servers start their schedulers at exactly the same time (the top of the hour). For more precise synchronisation, you might want to add a small random delay to avoid thundering herd problems:
func (rs *RetentionScheduler) StartWithJitter() {
// Add 0-60 seconds of jitter
jitter := time.Duration(rand.Intn(60)) * time.Second
time.Sleep(jitter)
rs.StartAtNextHour()
}
Unix timestamps vs time objects
One decision you'll face is whether to store Unix timestamps or use Go's time objects throughout your system. I've tried both approaches extensively.
Unix timestamps are great for:
- Database storage (integers are fast)
- JSON serialisation
- Cross-system compatibility
type Event struct {
ID int64 `json:"id"`
CreatedAt int64 `json:"created_at"` // Unix timestamp
}
// Converting between Unix and time.Time
event := Event{
ID: 123,
CreatedAt: time.Now().Unix(),
}
// Convert back to time.Time when you need it
createdTime := time.Unix(event.CreatedAt, 0)
But time.Time objects are better for:
- Timezone handling
- Duration calculations
- Date arithmetic
My current approach is to use Unix timestamps for storage and JSON, but convert to time.Time objects as soon as I need to do any calculations:
func (e *Event) CreatedTime() time.Time {
return time.Unix(e.CreatedAt, 0)
}
func (e *Event) IsOlderThan(duration time.Duration) bool {
return time.Since(e.CreatedTime()) > duration
}
Database Timeouts and Performance Monitoring
Database operations are where time handling becomes critical for reliability. You need timeouts that actually work, and monitoring that shows you what's happening.
Context-based query timeouts
The key to reliable database timeouts is using context properly. Here's the pattern I use:
type DatabaseClient struct {
db *sql.DB
}
func (dc *DatabaseClient) QueryWithTimeout(query string, timeout time.Duration, args ...interface{}) (*sql.Rows, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return dc.db.QueryContext(ctx, query, args...)
}
func (dc *DatabaseClient) GetUserData(userID int64) ([]UserData, error) {
rows, err := dc.QueryWithTimeout(
"SELECT id, data, created_at FROM user_data WHERE user_id = $1",
5*time.Second, // 5 second timeout
userID,
)
if err != nil {
return nil, fmt.Errorf("query timeout or error: %w", err)
}
defer rows.Close()
var results []UserData
for rows.Next() {
var ud UserData
err := rows.Scan(&ud.ID, &ud.Data, &ud.CreatedAt)
if err != nil {
return nil, err
}
results = append(results, ud)
}
return results, nil
}
Building timing into database structs
For monitoring database performance, I embed timing directly into my database client. This gives you metrics without changing your application code:
type TimedDB struct {
db *sql.DB
metrics map[string]time.Duration
mutex sync.RWMutex
}
func NewTimedDB(db *sql.DB) *TimedDB {
return &TimedDB{
db: db,
metrics: make(map[string]time.Duration),
}
}
func (tdb *TimedDB) QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) {
start := time.Now()
defer func() {
duration := time.Since(start)
// Simple query identification - first few words
queryType := strings.Fields(query)[0]
tdb.mutex.Lock()
tdb.metrics[queryType] = duration
tdb.mutex.Unlock()
if duration > 100*time.Millisecond {
log.Printf("Slow query (%s): %s took %v", queryType, query, duration)
}
}()
return tdb.db.QueryContext(ctx, query, args...)
}
func (tdb *TimedDB) GetMetrics() map[string]time.Duration {
tdb.mutex.RLock()
defer tdb.mutex.RUnlock()
// Return a copy to avoid race conditions
result := make(map[string]time.Duration)
for k, v := range tdb.metrics {
result[k] = v
}
return result
}
This approach automatically logs slow queries and gives you performance metrics without changing your existing code.
Connection timeout patterns
Database connections can hang in various ways. Here's a pattern that handles both connection timeouts and query timeouts:
func NewDatabaseWithTimeouts(dsn string) (*sql.DB, error) {
db, err := sql.Open("postgres", dsn)
if err != nil {
return nil, err
}
// Connection pool settings with timeouts
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(5 * time.Minute)
// Test the connection with a timeout
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
return nil, fmt.Errorf("database connection failed: %w", err)
}
return db, nil
}
Rate Limiting with Time Windows
Rate limiting is one of those features that seems simple until you implement it. I've built token bucket rate limiters, sliding window rate limiters, and fixed window rate limiters. Here's what actually works.
Token bucket rate limiter
The token bucket approach is elegant - you have a bucket that fills with tokens at a steady rate, and each request consumes a token. When the bucket is empty, requests are denied.
type TokenBucket struct {
capacity int
tokens int
refillRate time.Duration
lastRefill time.Time
mutex sync.Mutex
}
func NewTokenBucket(capacity int, refillRate time.Duration) *TokenBucket {
return &TokenBucket{
capacity: capacity,
tokens: capacity,
refillRate: refillRate,
lastRefill: time.Now(),
}
}
func (tb *TokenBucket) Allow() bool {
tb.mutex.Lock()
defer tb.mutex.Unlock()
// Refill tokens based on time elapsed
now := time.Now()
elapsed := now.Sub(tb.lastRefill)
if elapsed >= tb.refillRate {
tokensToAdd := int(elapsed / tb.refillRate)
tb.tokens = min(tb.capacity, tb.tokens+tokensToAdd)
tb.lastRefill = now
}
// Check if we can allow this request
if tb.tokens > 0 {
tb.tokens--
return true
}
return false
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
Per-user rate limiting with time windows
For API rate limiting, you typically want per-user limits. Here's a pattern using a map to track multiple users:
type UserRateLimiter struct {
buckets map[string]*TokenBucket
mutex sync.RWMutex
// Cleanup old buckets periodically
lastCleanup time.Time
}
func NewUserRateLimiter() *UserRateLimiter {
return &UserRateLimiter{
buckets: make(map[string]*TokenBucket),
lastCleanup: time.Now(),
}
}
func (url *UserRateLimiter) Allow(userID string) bool {
url.mutex.Lock()
defer url.mutex.Unlock()
// Get or create bucket for this user
bucket, exists := url.buckets[userID]
if !exists {
bucket = NewTokenBucket(100, time.Minute) // 100 requests per minute
url.buckets[userID] = bucket
}
// Cleanup old buckets every hour
if time.Since(url.lastCleanup) > time.Hour {
url.cleanup()
url.lastCleanup = time.Now()
}
return bucket.Allow()
}
func (url *UserRateLimiter) cleanup() {
// Remove buckets that haven't been used recently
cutoff := time.Now().Add(-time.Hour)
for userID, bucket := range url.buckets {
if bucket.lastRefill.Before(cutoff) {
delete(url.buckets, userID)
}
}
}
Using time.Now() effectively
time.Now()
is your starting point for most time operations. The key thing to understand is that it always returns the current time in the system's timezone:
now := time.Now()
fmt.Println(now) // 2024-01-15 14:30:45.123456789 +0000 UTC
// For consistent timestamps across systems, convert to UTC
utcNow := time.Now().UTC()
fmt.Println(utcNow) // 2024-01-15 14:30:45.123456789 +0000 UTC
When storing timestamps in databases or APIs, I always convert to UTC first to avoid timezone confusion across different environments.
Working with Golang Time Sleep and Delays
Sometimes you need to pause execution, whether for rate limiting, retry logic, or coordinating operations. Go provides several ways to handle this, and understanding when to use each approach matters for performance and correctness.
Using time.Sleep for simple delays
The most straightforward approach is time.Sleep
, but it blocks the current goroutine completely:
func processWithDelay() {
for i := 0; i < 5; i++ {
doWork(i)
time.Sleep(2 * time.Second) // Blocks for 2 seconds
}
}
This works fine for simple cases, but it's inflexible. You can't cancel the sleep early or coordinate with other operations.
Using time.After for cancellable delays
time.After
returns a channel that delivers the current time after the specified duration. This is more flexible because you can use it in select statements:
func processWithCancellableDelay(ctx context.Context) error {
for i := 0; i < 5; i++ {
doWork(i)
select {
case <-time.After(2 * time.Second):
// Continue after delay
case <-ctx.Done():
return ctx.Err() // Early cancellation
}
}
return nil
}
This pattern is particularly useful in production systems where you need graceful shutdown or cancellation capabilities.
HTTP Request Timing and Performance
Measuring HTTP request performance properly is crucial for understanding your application's behaviour. Here's how I add timing to HTTP handlers without cluttering the business logic.
Request timing middleware
The cleanest approach is middleware that wraps your handlers:
func TimingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Wrap the ResponseWriter to capture status code
wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(wrapped, r)
duration := time.Since(start)
log.Printf("%s %s - %d - %v",
r.Method,
r.URL.Path,
wrapped.statusCode,
duration)
// Log slow requests
if duration > 500*time.Millisecond {
log.Printf("SLOW REQUEST: %s %s took %v", r.Method, r.URL.Path, duration)
}
})
}
type responseWriter struct {
http.ResponseWriter
statusCode int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}
Processing time breakdown
For complex handlers, you might want to measure different phases:
func ComplexHandler(w http.ResponseWriter, r *http.Request) {
timer := NewProcessTimer()
// Database lookup
timer.Start("database")
user, err := getUserFromDB(r.Context(), userID)
timer.End("database")
if err != nil {
http.Error(w, "User not found", http.StatusNotFound)
return
}
// External API call
timer.Start("external_api")
data, err := fetchExternalData(user.APIKey)
timer.End("external_api")
if err != nil {
http.Error(w, "External API error", http.StatusServiceUnavailable)
return
}
// Processing
timer.Start("processing")
result := processData(data)
timer.End("processing")
// Response generation
timer.Start("response")
json.NewEncoder(w).Encode(result)
timer.End("response")
// Log the breakdown
timer.LogBreakdown(r.URL.Path)
}
type ProcessTimer struct {
phases map[string]time.Duration
start map[string]time.Time
}
func NewProcessTimer() *ProcessTimer {
return &ProcessTimer{
phases: make(map[string]time.Duration),
start: make(map[string]time.Time),
}
}
func (pt *ProcessTimer) Start(phase string) {
pt.start[phase] = time.Now()
}
func (pt *ProcessTimer) End(phase string) {
if startTime, exists := pt.start[phase]; exists {
pt.phases[phase] = time.Since(startTime)
delete(pt.start, phase)
}
}
func (pt *ProcessTimer) LogBreakdown(endpoint string) {
total := time.Duration(0)
for phase, duration := range pt.phases {
total += duration
log.Printf("%s - %s: %v", endpoint, phase, duration)
}
log.Printf("%s - total: %v", endpoint, total)
}
Common Gotchas and Best Practices
After years of working with Go's time package, here are the mistakes I see repeatedly and how to avoid them.
Timer and ticker cleanup
This is probably the most common source of goroutine leaks. Always stop your timers and tickers:
// Bad - goroutine leak
func badExample() {
ticker := time.NewTicker(time.Second)
for range ticker.C {
doSomething()
if shouldStop {
return // ticker never stopped!
}
}
}
// Good - proper cleanup
func goodExample() {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for range ticker.C {
doSomething()
if shouldStop {
return // defer will stop the ticker
}
}
}
Duration parsing edge cases
Parsing durations with time.ParseDuration
Go's duration parsing is powerful but has some quirks. Here's what works and what doesn't:
// These work as expected
d1, _ := time.ParseDuration("1h") // 1 hour
d2, _ := time.ParseDuration("30m") // 30 minutes
d3, _ := time.ParseDuration("45s") // 45 seconds
d4, _ := time.ParseDuration("1.5h") // 1 hour 30 minutes
d5, _ := time.ParseDuration("90m") // 1 hour 30 minutes (same as above)
// This fails - days aren't supported
_, err := time.ParseDuration("1d") // Error: time: unknown unit "d"
For handling days and other custom units, you'll need to build your own parser:
func parseDurationWithDays(s string) (time.Duration, error) {
if strings.HasSuffix(s, "d") {
days, err := strconv.Atoi(s[:len(s)-1])
if err != nil {
return 0, err
}
return time.Duration(days) * 24 * time.Hour, nil
}
return time.ParseDuration(s)
}
Timezone database updates
Your applications can break when timezone databases are updated. This is rare, but it happens:
// Safer timezone handling
func safeLoadLocation(name string) *time.Location {
loc, err := time.LoadLocation(name)
if err != nil {
log.Printf("Failed to load timezone %s: %v, falling back to UTC", name, err)
return time.UTC
}
return loc
}
// Use it like this
userTZ := safeLoadLocation("Europe/London")
localTime := utcTime.In(userTZ)
Time comparison precision
Go's time comparison can be more precise than you expect. For database timestamps, this can cause issues:
// Database timestamps often lose precision
original := time.Now()
stored := original.Truncate(time.Microsecond) // Simulate database storage
fmt.Println(original.Equal(stored)) // false!
// For database comparisons, truncate to appropriate precision
func timeEqual(t1, t2 time.Time) bool {
return t1.Truncate(time.Microsecond).Equal(t2.Truncate(time.Microsecond))
}
Performance considerations
Time operations are generally fast, but there are some patterns to avoid in hot paths:
// Expensive - creates new time objects repeatedly
func expensive() {
for i := 0; i < 1000000; i++ {
if time.Since(time.Now()) > time.Second {
// This is inefficient
}
}
}
// Better - calculate once
func efficient() {
start := time.Now()
deadline := start.Add(time.Second)
for i := 0; i < 1000000; i++ {
if time.Now().After(deadline) {
break
}
}
}
Conclusion
Working with time in Go is straightforward once you understand the fundamentals, but the production patterns matter more than the basics. The key insights that have saved me the most debugging time are:
Always be explicit about timezones when parsing external data. That 2am debugging session I mentioned at the start could have been avoided with proper timezone handling.
Use context timeouts for all database operations. Network calls can hang indefinitely, and context cancellation is your safety net.
Clean up your timers and tickers. This is the easiest way to leak goroutines, and it's completely avoidable with proper defer statements.
Build timing into your systems from the start. Adding performance monitoring after you have problems is much harder than building it in from day one.
The patterns I've shown here - retention schedulers, database timing, rate limiters, request monitoring - are the ones I use in production systems that handle real load. They're not the simplest implementations, but they're the ones that work reliably when things go wrong.
Time handling might seem like a boring topic, but it's one of those foundational skills that makes the difference between systems that work in development and systems that work in production. Get it right early, and you'll avoid a lot of late-night debugging sessions.
If you're building systems that handle scheduling, rate limiting, or time-based logic, I'd recommend starting with these patterns and adapting them to your specific needs. The extra complexity upfront pays for itself the first time your schedulers run correctly across multiple servers, or when your rate limiter handles a traffic spike without falling over.
For more production patterns and practical Go development tips, you might find my articles on 80x caching improvement in Go and building a rules engine for trading useful. I've also written about automating Git commits using AI if you're interested in developer productivity tools. The same attention to production reliability applies across all these systems.