The gap between having racing data and having a model is mostly feature engineering. A table of results is a record of what happened. A model needs, for each runner in each race, a row describing what was known going in. Building that row from a results table is a specific and slightly fiddly job, and most of the mistakes are made in it.
This is a hands-on guide to feature engineering for horse racing, with SQL you can run against the records and races tables and a short Python example for pace.
Rolling form, without peeking
The most common feature is some summary of a horse’s recent runs: average finishing position over the last five, days since it last ran, how many times it has raced before. The trap is including the race you are predicting. A window function with the right frame avoids it:
SELECT rec.Race_ID, rec.Horse_ID, r.Date, rec.Place,
AVG(CAST(rec.Place AS UNSIGNED)) OVER w5 AS avg_place_last5,
COUNT(*) OVER wall AS previous_runs,
DATEDIFF(r.Date, LAG(r.Date) OVER w) AS days_since_last_run
FROM records rec
JOIN races r ON r.Race_ID = rec.Race_ID
WHERE rec.Place REGEXP '^[0-9]+$'
WINDOW w AS (PARTITION BY rec.Horse_ID ORDER BY r.Date, r.race_time, rec.Race_ID),
w5 AS (w ROWS BETWEEN 5 PRECEDING AND 1 PRECEDING),
wall AS (w ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING);
The key is 1 PRECEDING: the frame stops one row before the current race. On a real horse’s first run this returns no average and zero previous runs, and on its second it returns the first run’s position, which is exactly what you could have known. We ran this against one horse’s career to make sure the numbers line up.
Two details matter. Order by date and then by race time, because a horse can run twice in a day. And filter to numeric places first, as above, because codes like PU and F are not positions. Note that the filter also drops those runs from the count of previous runs. Keep them in a separate feature of their own, such as “pulled up in the last three runs”. Our result codes guide lists them.
Thin histories
A third of UK horses have fewer than five runs in the archive, and 8.7% have only one. That has a direct effect on rolling features: an average over five runs is an average over one for a horse that has raced once. Always carry the count of previous runs as its own column, and consider shrinking short-history averages towards the population mean so that one lucky run does not look like a pattern.
Pace from the splits
Where sectional times exist, they give you a second layer of features that finishing position cannot. A simple one is how a horse’s last stretch compares with the middle of its race:
import pandas as pd
splits = [c for c in df.columns if c.startswith("sectional_time_")]
def closing_ratio(row):
s = [row[c] for c in splits if pd.notna(row[c])]
if len(s) < 4:
return None
middle = s[1:-1]
return s[-1] / (sum(middle) / len(middle))
A ratio above 1 means the horse was slower in the final stretch than in the middle of the race, and below 1 means it was quicker. For the Kempton winner we use in our sectional times guide, the splits 14.01, 11.40, 11.61, 11.67, 11.52 and 12.24 give a closing ratio of about 1.06, so it slowed at the end.
This is a feature of a past race, so for a new race you aggregate it across the horse’s earlier runs, again stopping before the current one. Remember that these splits exist for about 91% of British runners since 2024 and for no Irish ones. Read the coverage by year before you decide how to treat the gaps.
Context features
Some features describe the race, not the horse: distance in metres, going, class, field size, and the horse’s draw. The distances and surfaces post shows how to turn the text values into numbers. A few reminders.
Draw only exists on the flat. Over jumps it is empty, so interact it with the race type or leave it out.
Going is text with qualifiers such as “Good to Firm (Good in places)”. Parse it into a number and keep the original.
Field size is known only at declaration time. Use the declared number of runners from the racecard, not the final one, which can change if horses are withdrawn.
Jockeys and trainers
Rolling strike rates for jockeys and trainers, over the last 14 or 30 days, are standard. They have the same trap as horse form: compute them from results strictly before the race date, and require a minimum number of rides before you trust the rate. A jockey with two rides and one winner has a 50% strike rate and no information.
Test the leakage, not just the accuracy
A cheap check is to shuffle a feature’s values across rows. If model accuracy barely moves, the feature contributes nothing. If a feature quietly contains the answer, accuracy collapses when you shuffle it. A drop that large from one column is a warning sign worth investigating.
None of this is hard. It is fiddly, and the fiddly parts are where the leaks hide. The UK and Hong Kong tables are in the datasets and the API, and machine learning and horse racing data gives the wider picture.

Comments are closed