Every basic strategy chart tells you what to do with 16 against a dealer 10. None of them tell you by how much it's the right call. So I wrote a solver that prints the number: the exact expected value (EV) of hit, stand, double and split for all 340 player-hand vs dealer-upcard situations.
It's plain Python, no dependencies, and it solves the whole table in well under a second. The code and the data are on GitHub: SlotDropApp/blackjack-ev.
Why recursion instead of Monte Carlo
The usual approach is to simulate a few hundred million hands and read off averages. That works, but you get noise in the third decimal, and the interesting decisions in blackjack are decided in the third or fourth decimal.
Blackjack has a tiny state space if you model the shoe as infinite (each rank drawn with its nominal probability: 1/13, and 4/13 for ten-valued cards). A hand is just (total, soft). That means every quantity can be computed exactly by recursion with memoisation.
The infinite-deck assumption is the standard one for published EV tables. Against a six-deck shoe the difference is around a thousandth of a bet, and the README says so up front.
Step 1: the dealer's final total
Everything starts from one function: given what the dealer holds, what is the probability distribution of their final total?
CARDS = [(1, 1 / 13)] + [(v, 1 / 13) for v in range(2, 10)] + [(10, 4 / 13)] @lru_cache(maxsize=None) def _dealer(total, soft): """Distribution of the dealer's final totals: {17..21: p, 'bust': p}.""" if total >= 17: return {total: 1.0} out = {} for c, p in CARDS: t, s = add(total, soft, c) sub = {"bust": 1.0} if t > 21 else _dealer(t, s) for k, q in sub.items(): out[k] = out.get(k, 0.0) + p * q return out add() handles the ace dropping from 11 to 1. The dealer stands on soft 17 here, which is why the base case is simply total >= 17.
The bug almost everyone ships
When the dealer shows an ace or a ten, most US-style games let them peek for blackjack before you act. If they have it, the hand is over. So the situations you actually face against a 10 are the ones where the hole card is not an ace.
If you skip that conditioning, every EV against an ace or a ten comes out too low. The fix is to drop the blackjack hole card and renormalise:
if up == 10: excluded = 1 / 13 # ace in the hole -> blackjack ... out[k] = out.get(k, 0.0) + (p / (1 - excluded)) * q With that in place, the dealer busts 42.3% of the time showing a 6, 23.0% showing a 10, and 16.7% showing an ace.
Step 2: the player's options
Standing is just a comparison against the dealer distribution:
def ev_stand(total, up): ev = 0.0 for final, p in dealer(up).items(): if final == "bust" or total > final: ev += p elif total < final: ev -= p return ev Hitting is "take one card, then play optimally from wherever you land", which is where the recursion pays off:
@lru_cache(maxsize=None) def ev_optimal(total, soft, up): """Best EV reachable from this state: stand, or keep drawing.""" if total > 21: return -1.0 stand = ev_stand(total, up) draw = 0.0 for c, p in CARDS: n, s = add(total, soft, c) draw += p * (-1.0 if n > 21 else ev_optimal(n, s, up)) return max(stand, draw) Doubling is one card and a forced stand, with the stake multiplied by two. Splitting plays two hands from the pair's rank, with doubling allowed after the split. Two simplifications are stated in the code: there is no resplitting, and split aces get one card each. Both make splits look very slightly worse than they really are.
Step 3: test against something you didn't generate
A test that derives its expected values from the code under test only proves that a file equals itself. So reference.py is a basic strategy chart typed in by hand from published tables, and validate.py compares all 340 cells against it:
340 cells compared 338 identical 2 documented marginal 0 unexplained The two disagreements are both soft hands where hitting and doubling are nearly tied:
| hand | chart says | solver says | margin |
|---|---|---|---|
| A,2 vs 5 | double | hit | 0.00741 |
| A,4 vs 4 | double | hit | 0.00086 |
Neither source is "wrong". Published charts disagree with each other on exactly these cells. The validator accepts these two only while their margin stays under 0.01; any other mismatch fails the run.
What the numbers say
Once you have every EV, the interesting column is the gap between the best and second-best move, i.e. what a mistake costs.
- 16 vs 10 is the closest call in the game. Hitting is worth −0.53983 and standing −0.54043. Hit wins by 0.0006 of a bet, about 6 cents per $100 wagered. You lose either way; hitting just loses slightly less. (With surrender allowed, surrendering beats both.)
- A,4 vs 4 is the next closest, at 0.00086, followed by A,7 vs 2 (stand over double, 0.00199) and 12 vs 4 (stand over hit, 0.00247).
- At the other end, hitting a hard 20 against a 7 or an 8 throws away more than 1.6 bets on average.
That's the point of printing the margin: a chart treats "hit 16 vs 10" and "split aces" as equally important rules, and they really aren't.
The data
The full table is published as CSV and JSON under CC BY 4.0, with the EV of every move, the cost of the second-best move, the dealer's bust probability and the player's one-card bust probability:
- GitHub: SlotDropApp/open-data
- Hugging Face: slotdropapp/blackjack-hands
- Kaggle: Blackjack: expected value of every hand and move
The same numbers are on an interactive chart at slotdropapp.com/blackjack-charts.html. I run that site, which is funded by a casino affiliate link; the solver and the data don't depend on it.
Rules modelled: infinite deck, dealer stands on soft 17, dealer peeks, blackjack pays 3:2, double on any two cards, double after split, no resplit, no surrender. Changing a rule is a small edit, and the issues tab is open if you want H17 or surrender variants.

