Skip to content

From Skyfield to SQL

Skyfield is an excellent Python library for positional astronomy — well-documented, well-tested, and built on the same JPL ephemeris data used by spacecraft navigation teams. If you already use Skyfield, you’ll recognize the computations pg_orbit performs. The difference is where they happen.

This page shows the same tasks done both ways. Not to argue one is better than the other — they make different trade-offs — but to help you decide which fits your workflow.

The most common starting point: where is Jupiter from my location, right now?

from skyfield.api import load, Topos
ts = load.timescale() # downloads finals2000A.all
eph = load('de421.bsp') # downloads 17MB BSP file
observer = Topos('40.0 N', '105.3 W', elevation_m=1655)
t = ts.now()
astrometric = (eph['earth'] + observer).at(t).observe(eph['jupiter barycenter'])
alt, az, distance = astrometric.apparent().altaz()
print(f"Az: {az.degrees:.2f} El: {alt.degrees:.2f} Dist: {distance.au:.4f} AU")

Before this runs, Skyfield downloads two files:

  • de421.bsp (17 MB) — JPL planetary ephemeris
  • finals2000A.all (3.5 MB) — Earth orientation parameters

These files expire. finals2000A.all needs refreshing every few months. The BSP file itself is stable, but managing file paths across environments (local dev, CI, production) adds friction.

Observe many satellites at the same timestamp. This is where the architectural difference starts to matter.

from skyfield.api import load, EarthSatellite, Topos
ts = load.timescale()
observer = Topos('40.0 N', '105.3 W', elevation_m=1655)
t = ts.now()
# Load TLE file
with open('catalog.tle') as f:
lines = f.readlines()
results = []
for i in range(0, len(lines), 3):
name = lines[i].strip()
sat = EarthSatellite(lines[i+1], lines[i+2], name, ts)
topo = (sat - observer).at(t)
alt, az, dist = topo.altaz()
if alt.degrees > 0:
results.append({
'name': name,
'az': az.degrees,
'el': alt.degrees,
'range_km': dist.km
})
# Now you have results in a Python list.
# To correlate with other data, you need to:
# 1. Load that data from your database
# 2. Match by satellite name or NORAD ID
# 3. Merge in Python (pandas, dict lookup, etc.)

The results live in Python memory. If you need to correlate with operator contact schedules, frequency assignments, or historical observation logs that live in PostgreSQL, you have to bridge two systems.

Generate positions over a time range — for plotting an elevation profile, building a ground track, or analyzing visibility windows.

from skyfield.api import load, Topos
import numpy as np
ts = load.timescale()
eph = load('de421.bsp')
observer = Topos('40.0 N', '105.3 W', elevation_m=1655)
# Generate 144 timestamps over 24 hours
t0 = ts.now()
t1 = ts.tt_jd(t0.tt + 1.0)
times = ts.linspace(t0, t1, 144)
earth_obs = eph['earth'] + observer
jupiter = eph['jupiter barycenter']
elevations = []
for t in times:
alt, az, dist = earth_obs.at(t).observe(jupiter).apparent().altaz()
elevations.append(alt.degrees)
# Plot with matplotlib
import matplotlib.pyplot as plt
plt.plot(range(len(elevations)), elevations)
plt.ylabel('Elevation (degrees)')
plt.show()

The loop is explicit. For 144 points this is fast, but the pattern doesn’t parallelize automatically. For larger sweeps (thousands of satellites, days of 1-minute resolution), you manage the iteration yourself.

Predict when a satellite will be visible from a location. This is where Skyfield and pg_orbit take genuinely different approaches.

from skyfield.api import load, EarthSatellite, Topos
ts = load.timescale()
observer = Topos('40.0 N', '105.3 W', elevation_m=1655)
line1 = '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025'
line2 = '2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'
sat = EarthSatellite(line1, line2, 'ISS', ts)
t0 = ts.now()
t1 = ts.tt_jd(t0.tt + 1.0)
# find_events returns (times, event_types)
# event_type: 0=rise, 1=culminate, 2=set
times, events = sat.find_events(observer, t0, t1, altitude_degrees=10.0)
for ti, event in zip(times, events):
name = ('rise', 'culminate', 'set')[event]
print(f"{ti.utc_iso()}{name}")

Skyfield’s find_events uses root-finding to locate the exact moments when elevation crosses the threshold. This gives sub-second precision for AOS and LOS times.

Precision. Skyfield uses the full IAU 2000A nutation model, polar motion corrections, and delta-T from IERS data. When you need sub-arcsecond accuracy — dish pointing at microwave frequencies, occultation timing, precision astrometry — Skyfield with DE441 ephemerides is the right tool.

Rise/set finding. find_events() uses numerical root-finding to pinpoint the exact moment a body crosses an elevation threshold. pg_orbit’s predict_passes uses a step-and-refine approach that’s fast for batches but less precise for individual events.

Aberration and light-time. Skyfield iterates to correct for light travel time and applies stellar aberration. pg_orbit uses geometric positions without light-time iteration — the difference is under 20 arcseconds for planets and irrelevant for satellite tracking, but it matters for some applications.

Visualization integration. Skyfield works directly with matplotlib, numpy, and the rest of the Python scientific stack. pg_orbit produces rows and columns — you export to CSV or JSON and then plot separately.

Extensibility. Skyfield handles arbitrary BSP kernels — Pluto, spacecraft, asteroids with precise ephemerides. pg_orbit’s body catalog is fixed at compile time.

No file management. No BSP kernels, no timescale data files, no expiring Earth orientation parameters. The computation ships with the extension.

Batch operations at database speed. Observing 12,000 satellites in 17ms. 22,500 Lambert solves in 8.3 seconds. These aren’t optimized benchmarks — they’re SELECT statements running on commodity hardware.

Data correlation. The computation happens where your data lives. JOIN orbital results with contact schedules, frequency assignments, observation logs, or any other table. No ETL pipeline between Python and PostgreSQL.

Automatic parallelism. PostgreSQL’s query planner distributes PARALLEL SAFE functions across available cores. You don’t manage threads or multiprocessing pools.

Reproducibility. A SQL query is a complete, self-contained specification of a computation. No virtual environment, no package versions, no file paths. The same query produces the same result on any PostgreSQL instance with pg_orbit installed.

You don’t have to choose one or the other. A practical migration path:

  1. Keep Skyfield for precision work. Anything requiring sub-arcsecond accuracy, aberration corrections, or custom BSP kernels stays in Python.

  2. Move batch observation to SQL. If you’re computing positions for hundreds of objects to filter or correlate with database records, pg_orbit eliminates the Python-to-PostgreSQL round trip.

  3. Move scheduling to SQL. Pass prediction and visibility windows over time ranges are natural generate_series + predict_passes queries.

  4. Move reporting to SQL. “What was above 20 degrees from each of our 5 observers last night?” is a single query with a CROSS JOIN, not a Python loop over observers and timestamps.