Skip to content

From JPL Horizons to SQL

JPL Horizons is the gold standard for solar system ephemeris data. Run by the Solar System Dynamics group at the Jet Propulsion Laboratory, it serves precise positions for every known body — planets, moons, asteroids, comets, spacecraft. You can access it through a web interface, telnet, email, or REST API.

pg_orbit does not replace Horizons. What it does is move the 95% of queries that don’t need sub-milliarcsecond precision from a remote API into your local database — with no rate limits, no network latency, and results that join directly with your other tables.

The most common Horizons request: “Where is Mars from my location at this time?”

import requests
params = {
'format': 'json',
'COMMAND': '499', # Mars
'OBJ_DATA': 'NO',
'MAKE_EPHEM': 'YES',
'EPHEM_TYPE': 'OBSERVER',
'CENTER': 'coord@399',
'COORD_TYPE': 'GEODETIC',
'SITE_COORD': '-105.3,40.0,1.655', # lon, lat, alt(km)
'START_TIME': '2025-06-15 00:00',
'STOP_TIME': '2025-06-15 00:01',
'STEP_SIZE': '1',
'QUANTITIES': '1,4,20', # Astrometric RA/Dec, Az/El, Range
}
response = requests.get(
'https://ssd.jpl.nasa.gov/api/horizons.api',
params=params
)
data = response.json()
# Parse the text block in data['result']
# Horizons returns fixed-width text, not structured JSON
print(data['result'])

The API returns a text block with column headers embedded in the response body. Parsing it requires knowing the column positions or using a library like astroquery.jplhorizons. The response format varies depending on which quantities you request.

Rate limits: JPL asks for no more than ~200 heavy queries per hour from a single IP. Automated batch jobs that generate thousands of queries risk being throttled or blocked.

This is where the workflow difference becomes dramatic. Generating a 24-hour elevation profile at 10-minute resolution means 144 data points.

import requests
# Option A: Single request with STEP_SIZE
params = {
'format': 'json',
'COMMAND': '599', # Jupiter
'MAKE_EPHEM': 'YES',
'EPHEM_TYPE': 'OBSERVER',
'CENTER': 'coord@399',
'COORD_TYPE': 'GEODETIC',
'SITE_COORD': '-105.3,40.0,1.655',
'START_TIME': '2025-06-15 00:00',
'STOP_TIME': '2025-06-16 00:00',
'STEP_SIZE': '10m', # 10-minute intervals
'QUANTITIES': '4', # Az/El only
}
response = requests.get(
'https://ssd.jpl.nasa.gov/api/horizons.api',
params=params
)
# Parse 144 lines of fixed-width text
# Extract azimuth and elevation from each line
lines = response.json()['result'].split('\n')
# ... parsing logic ...

For a single body and time range, Horizons handles this in one request. But what if you want this for all 8 planets? That’s 8 API calls. For 5 observers? That’s 40. For a full year at 1-hour resolution? You’re managing thousands of requests, rate limiting, error handling, and stitching results together.

Horizons excels at moons — it has ephemerides for every known natural satellite. pg_orbit covers the 19 most-observed moons.

import requests
# Galilean moons: Io=501, Europa=502, Ganymede=503, Callisto=504
moons = {'Io': '501', 'Europa': '502', 'Ganymede': '503', 'Callisto': '504'}
for name, code in moons.items():
params = {
'format': 'json',
'COMMAND': code,
'MAKE_EPHEM': 'YES',
'EPHEM_TYPE': 'OBSERVER',
'CENTER': 'coord@399',
'COORD_TYPE': 'GEODETIC',
'SITE_COORD': '-105.3,40.0,1.655',
'START_TIME': '2025-06-15 03:00',
'STOP_TIME': '2025-06-15 03:01',
'STEP_SIZE': '1',
'QUANTITIES': '1,4,20',
}
response = requests.get(
'https://ssd.jpl.nasa.gov/api/horizons.api',
params=params
)
# Parse each response separately...

Four separate API calls. To track all four moons over a night of observation at 5-minute intervals (say, 8 hours = 96 steps), that’s 4 requests or careful batching.

This is where the difference is most striking. Horizons doesn’t compute transfer orbits directly — you’d use its ephemeris data as input to your own Lambert solver. pg_orbit does both in one step.

from astroquery.jplhorizons import Horizons
from poliastro.iod import izzo
from astropy import units as u
import numpy as np
# Step 1: Get Earth and Mars positions from Horizons
# for each departure/arrival date pair
dep_dates = pd.date_range('2028-08-01', '2028-12-01', freq='5D')
arr_dates = pd.date_range('2029-04-01', '2029-09-01', freq='5D')
results = []
for dep in dep_dates:
# Query Earth heliocentric state at departure
earth = Horizons(id='399', location='@sun', epochs=dep.jd)
earth_vec = earth.vectors() # API call
for arr in arr_dates:
# Query Mars heliocentric state at arrival
mars = Horizons(id='499', location='@sun', epochs=arr.jd)
mars_vec = mars.vectors() # API call
# Solve Lambert problem
r1 = [earth_vec['x'][0], earth_vec['y'][0], earth_vec['z'][0]] * u.AU
r2 = [mars_vec['x'][0], mars_vec['y'][0], mars_vec['z'][0]] * u.AU
tof = (arr - dep).days * u.day
try:
(v1, v2), = izzo.lambert(Sun.k, r1, r2, tof)
c3 = (np.linalg.norm(v1.value) ** 2)
results.append({'dep': dep, 'arr': arr, 'c3': c3})
except:
pass
# For a 25x31 grid, that's 775 departure queries + 775 arrival queries
# to Horizons, plus 775 Lambert solves in Python

The Horizons queries alone — even with careful batching — take minutes and risk rate limiting. The Lambert solve is the easy part.

Accuracy. DE441 provides sub-milliarcsecond planetary positions. pg_orbit’s VSOP87 is accurate to about 1 arcsecond — a factor of 1000 less precise. For spacecraft navigation, radar astrometry, or occultation timing, Horizons is the correct source.

Aberration corrections. Horizons applies light-time iteration, stellar aberration, and gravitational deflection of light. pg_orbit uses geometric positions only.

Physical properties. Horizons can return visual magnitude, angular diameter, phase angle, illuminated fraction, and surface brightness. pg_orbit returns geometric position and range.

Topographic corrections. Horizons accounts for Earth’s oblateness and topographic features at the observer’s location using precise geodetic models. pg_orbit uses a WGS84 ellipsoid.

Body catalog. Horizons knows about every numbered asteroid, every known comet, and spacecraft past and present. pg_orbit covers the 8 planets, the Sun, the Moon, 19 planetary moons, and whatever comets/asteroids you define with Keplerian elements.

No network dependency. pg_orbit runs locally, in your database process. No DNS resolution, no TLS handshake, no API parsing. Useful in air-gapped environments, on aircraft, or when Horizons is down for maintenance.

No rate limits. Horizons is generous but not unlimited. Automated pipelines that generate thousands of queries — pork chop plot surveys, Monte Carlo trajectory analysis, multi-body scheduling — can hit throttling. pg_orbit has no external limits; you’re bounded only by your own hardware.

Batch everything locally. The Lambert transfer example above illustrates this best. What takes hundreds of API calls and minutes of wall-clock time in the Horizons workflow is a single query that runs in seconds.

Results in your database. Horizons returns text that you parse and then insert. pg_orbit results are already rows in PostgreSQL — ready to JOIN, index, aggregate, or export.

Reproducibility. A pg_orbit query is deterministic. Given the same inputs, it produces the same output on any PostgreSQL instance with the extension installed. No dependency on the current state of a remote API or the version of its ephemeris files.

For many projects, the right approach uses both.

  1. Use Horizons for calibration. Run the same computation in both systems and compare. pg_orbit should agree with Horizons to within about 1 arcsecond for planets and a few arcseconds for moons. If the difference matters for your application, use Horizons.

  2. Use pg_orbit for surveys. Any time you need positions for many bodies, many timestamps, or many observers — parameter sweeps, scheduling optimization, catalog screening — run it locally.

  3. Use pg_orbit for integration. When orbital data needs to join with other database tables — observation logs, equipment schedules, frequency allocations — computing inside PostgreSQL eliminates the ETL step.

  4. Use Horizons for exotic bodies. If you need positions for Pluto, numbered asteroids with precise osculating elements, or decommissioned spacecraft, Horizons is the only option.