{"id":236,"date":"2026-11-03T12:00:00","date_gmt":"2026-11-03T12:00:00","guid":{"rendered":"https:\/\/rezachegini.com\/?p=236"},"modified":"2026-11-03T20:21:14","modified_gmt":"2026-11-03T20:21:14","slug":"building-a-job-board-api-part-6-the-skill-match-score","status":"publish","type":"post","link":"https:\/\/rezachegini.com\/?p=236","title":{"rendered":"Building a Job Board API, Part 6: The Skill Match Score"},"content":{"rendered":"\n<h2 class=\"wp-block-heading\">Where we are<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">In the last part, I added four permission classes to my Job Board API, so every user can only do what their role allows. That was the first half of Phase 2&#8217;s goal: wrong user types get 403.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This part is the second half: match scores return correctly.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The match score is a number from 0 to 100. It tells a company how many of a job&#8217;s required skills a candidate actually has. In this part I built that calculation as a small pure function, and then showed its result inside the application data the API already returns.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">It is a small feature. But it is also the first piece of real business logic in the project. Everything before this was plumbing: models, login, permissions. This is the first code that answers a question a user actually cares about: how well does this person fit this job?<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">It also uses the PostgreSQL array fields I designed back in Phase 1, Part 2. That decision is finally paying rent.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The rule<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The score answers one question: how much of what the job needs does this candidate have?<\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><img loading=\"lazy\" decoding=\"async\" width=\"1488\" height=\"952\" src=\"https:\/\/rezachegini.com\/wp-content\/uploads\/2026\/11\/job-board-api-part-6-match-diagram.png\" alt=\"Match score worked example diagram\" class=\"wp-image-239\" srcset=\"https:\/\/rezachegini.com\/wp-content\/uploads\/2026\/11\/job-board-api-part-6-match-diagram.png 1488w, https:\/\/rezachegini.com\/wp-content\/uploads\/2026\/11\/job-board-api-part-6-match-diagram-300x192.png 300w, https:\/\/rezachegini.com\/wp-content\/uploads\/2026\/11\/job-board-api-part-6-match-diagram-1024x655.png 1024w, https:\/\/rezachegini.com\/wp-content\/uploads\/2026\/11\/job-board-api-part-6-match-diagram-768x491.png 768w\" sizes=\"auto, (max-width: 1488px) 100vw, 1488px\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Three details in this example matter:<\/p>\n\n\n\n<ul class=\"wp-block-list\"><li>&#8221; Python &#8221; matches &#8220;python&#8221;. Case and extra spaces are ignored. In Phase 1, I wrote down this exact requirement: matching must not care about case or extra spaces. I am happy I wrote it down, because the code below is where it had to be true.<\/li><li>aws changes nothing. Only the required skills count. A candidate with extra skills is not punished, and a candidate who lists fifty unrelated skills does not get a fake boost.<\/li><li>The result is a whole number. 2 out of 3 is 66.67%, which becomes 67.<\/li><\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">And one special case: if a job requires no skills at all, the score is null (empty), not 100. I explain why in the decisions section.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Step 1: A pure function in matching.py<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The whole rule lives in a new file, <code>jobs\/matching.py<\/code>. It is only two small functions, and it imports nothing from Django.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Cleaning a list of skills<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>def normalize_skills(skills):\n    \"\"\"Lowercase, trim and de-duplicate a list of skill names.\"\"\"\n    if not skills:\n        return set()\n    return {skill.strip().lower() for skill in skills if skill and skill.strip()}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">This is a set comprehension. It looks like a list comprehension, but with curly braces {} instead of square brackets, so Python builds a set. Reading the inside, for each skill in the list:<\/p>\n\n\n\n<ul class=\"wp-block-list\"><li><code>skill.strip().lower()<\/code> removes spaces at the start and end, and makes everything lowercase. &#8221; Python &#8221; becomes &#8220;python&#8221;.<\/li><li><code>if skill and skill.strip()<\/code> throws away empty values. <code>None<\/code> and <code>\"\"<\/code> fail the first part. A string of only spaces, &#8221; &#8220;, passes the first part, but its strip is empty, so it fails the second.<\/li><li>Why throw them away? Without this filter, an empty string would count as a skill. A job with [&#8220;python&#8221;, &#8220;&#8221;, &#8221; &#8220;] would look like it requires 3 skills when it really requires 1.<\/li><li>Why a set? Because duplicates disappear. If a company accidentally lists python twice, it still counts once.<\/li><\/ul>\n\n\n\n<h3 class=\"wp-block-heading\">Calculating the score<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>def match_score(required_skills, candidate_skills):\n    \"\"\"Percentage (0-100) of required skills the candidate has, or None.\"\"\"\n    required = normalize_skills(required_skills)\n    if not required:\n        return None\n    matched = required &amp; normalize_skills(candidate_skills)\n    return round(100 * len(matched) \/ len(required))<\/code><\/pre>\n\n\n\n<ul class=\"wp-block-list\"><li><code>if not required:<\/code> uses the fact that an empty set is false in Python. If the job needs nothing, we return None before we can divide by zero.<\/li><li><code>required &amp; normalize_skills(candidate_skills)<\/code> is set intersection: the skills that appear in both sets. This one symbol, &amp;, is the whole comparison.<\/li><li><code>len(matched) \/ len(required)<\/code> gives a fraction. In Python 3, \/ always returns a decimal number: 2 \/ 3 is 0.666&#8230;, not 0. (Older Python, and many other languages, would give 0 for two whole numbers. A classic bug source.)<\/li><li><code>round(...)<\/code> turns 66.67 into the whole number 67. A small Python detail I learned here: <code>round()<\/code> uses &#8220;round half to even&#8221;, so <code>round(2.5)<\/code> is 2, not 3. It does not hurt here, but it surprised me.<\/li><\/ul>\n\n\n\n<h3 class=\"wp-block-heading\">Why a pure function?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">A pure function depends only on its inputs and changes nothing outside itself: no database, no request, no global state. Same inputs, same answer, every time. That buys two things:<\/p>\n\n\n\n<ol class=\"wp-block-list\"><li>It is very easy to test. I can call it with two plain lists and check the answer. No database, no users, no login. You will see this in Step 3.<\/li><li>It is reusable. It takes two lists, not a Job or an Application. So later I could use the same function to rank jobs for a candidate (&#8220;which jobs fit me best?&#8221;) without changing a line of it.<\/li><\/ol>\n\n\n\n<h2 class=\"wp-block-heading\">Step 2: Showing the score in the API<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The function exists. Now the API needs to return it. I changed <code>ApplicationSerializer<\/code> in three small places.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The import<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>from . import matching<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">I import the module, not the function. So the call reads <code>matching.match_score(...)<\/code>. Why? Because the name <code>match_score<\/code> will also be a serializer field (next step), and both would live in the same file. Keeping the module name in front means the two can never collide. This is a naming problem I now think about before it bites, not after. Part 4 and Part 5 both bit me with names.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The field<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>match_score = serializers.SerializerMethodField()<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">A <code>SerializerMethodField<\/code> is a field whose value comes from a method called <code>get_&lt;field name&gt;<\/code>. It is also read-only by design: a candidate cannot POST a <code>match_score<\/code> of 100 for themselves, because the value is always calculated by the server. (That is a nice free safety property. A writable score field would be a bug and a cheat code in one.)<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">I also added &#8220;match_score&#8221; to the <code>fields<\/code> list in <code>Meta<\/code>, right after &#8220;status&#8221;, so the two values a company cares about sit next to each other in the JSON.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The method<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>def get_match_score(self, obj):\n    return matching.match_score(obj.job.required_skills, obj.candidate.skills)<\/code><\/pre>\n\n\n\n<ul class=\"wp-block-list\"><li>DRF finds this method by its name: <code>get_<\/code> plus the field name. And here is a nice contrast with my last two parts: this name lookup fails loudly. If I had typed <code>get_matchscore<\/code>, DRF would raise an error the first time the serializer runs. Not silently wrong like <code>validateJob<\/code> in Part 4, and not silently ignored like a permission class inside <code>Meta<\/code> in Part 5. Some framework names shout, some whisper. This one shouts.<\/li><li>I placed the method at the class level, next to <code>validate_job<\/code>. Not inside <code>Meta<\/code>. (I learned that one the hard way too.)<\/li><li>No extra database queries. My views already load each application&#8217;s job and candidate in one SQL JOIN (I used <code>select_related<\/code> for that in Phase 1). So <code>obj.job<\/code> and <code>obj.candidate<\/code> are already in memory when this runs. The score is pure Python set work on two small lists.<\/li><\/ul>\n\n\n\n<h3 class=\"wp-block-heading\">No new permission code<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">I didn&#8217;t write any new permission rules for the score, and I didn&#8217;t need to. The score is part of the application object, and the application already has its permissions from Part 5: only the candidate who owns it and the company that owns the job can read it. A new field inside an object inherits that object&#8217;s protection. But &#8220;inherits&#8221; is a claim, not a test. So I tested it anyway (the 403 case in Step 3). New surface, same checks. That is the habit Part 5 taught me.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Step 3: Testing it<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Seven cases, no database<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Because <code>match_score<\/code> is a pure function, I could test it with plain lists in the Django shell. No users, no login, no seeding. This alone justified the pure-function design, and it took about a minute:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>from jobs.matching import match_score\n\ncases = [\n    ([\"python\", \"docker\", \"kubernetes\"], [\"Python\", \"docker\", \"aws\"], 67),\n    ([\"python\", \"docker\", \"kubernetes\"], [\"python\", \"docker\", \"kubernetes\", \"aws\"], 100),\n    ([\"python\", \"docker\"], [\"go\", \"rust\"], 0),\n    ([], [\"python\"], None),\n    ([\"python\"], [\" python \"], 100),\n    ([\"python\", \"docker\"], [\"python\", \"python\"], 50),\n    ([\"python\", \"\", \" \"], [\"python\"], 100),\n]\n\nfor required, candidate, expected in cases:\n    result = match_score(required, candidate)\n    print(required, candidate, \"-&gt;\", result, \"expected\", expected, \"OK\" if result == expected else \"FAIL\")<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">This style is called a table-driven test. Each line is one case: the input and the expected answer. You read it like a table, and adding a new case is adding one line.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">What each case proves:<\/p>\n\n\n\n<ol class=\"wp-block-list\"><li>67: a partial match, and <code>Python<\/code> matches <code>python<\/code>. It also checks the rounding of 66.67.<\/li><li>100: the extra skill <code>aws<\/code> doesn&#8217;t change the score.<\/li><li>0: no overlap gives 0, not an error and not None.<\/li><li>None: a job with no required skills gives &#8220;no score&#8221;, and no division by zero.<\/li><li>100: spaces around a skill are ignored.<\/li><li>50: a duplicated <code>python<\/code> counts once. It is 1 of 2, not 2 of 3.<\/li><li>100: empty entries are ignored. It is 1 of 1, not 1 of 3.<\/li><\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">All seven printed OK.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Through the API<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Then I checked it end to end. Job 1 (Acme&#8217;s) requires <code>python<\/code>, <code>docker<\/code> and <code>kubernetes<\/code>. Job 3 (Initech&#8217;s) requires two different skills. Reading real applications, with tokens, the scores came back inside the application JSON:<\/p>\n\n\n\n<ul class=\"wp-block-list\"><li>sam reads his application to job 1: 67. He has 2 of the 3 required skills.<\/li><li>Acme lists job 1&#8217;s applicants: sam 67, dana 67. dana has <code>python<\/code> and <code>docker<\/code>, also 2 of 3. The applications list now shows a score per person, side by side.<\/li><li>Initech lists job 3&#8217;s applicants: sam 50, riya 50. Each has 1 of the 2 required skills.<\/li><li>dana tries to read sam&#8217;s application: 403.<\/li><\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">That last one is a regression check. A regression is when something that used to work breaks after a change. Part 5 made applications private; adding a field should not have opened them up again, and it didn&#8217;t.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">With these results, the second half of Phase 2&#8217;s goal is met: match scores return correctly.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The decisions, and what they cost<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">No bugs this time. So instead, I want to be honest about the trade-offs I chose, because every one of them has a price I will pay later.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>1. The score is calculated when you read it, not stored.<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The score is never saved in the database. It is calculated again every time someone reads an application.<\/p>\n\n\n\n<ul class=\"wp-block-list\"><li>The good part: no database migration, and the score is always up to date. If a candidate adds a skill to their profile today, every score changes instantly and correctly.<\/li><li>The cost: the database doesn&#8217;t know the score, so I can&#8217;t sort or filter by it in SQL. A company can&#8217;t ask for &#8220;only applicants above 80&#8221; without the API first calculating every score in Python. For a small project that is fine. For a big one it is the first thing I would change.<\/li><li>The other option was a stored <code>match_score<\/code> column, set when the candidate applies. It is sortable, but it goes stale when skills change, unless I also add update logic everywhere skills can change. I chose fresh over sortable, on purpose.<\/li><\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>2. No required skills means null, not 100.<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If a job requires nothing, there is nothing to measure. A score of 100 would look like a perfect match, but it would really mean &#8220;we know nothing about this job&#8221;. That is misleading to a company making a decision. <code>null<\/code> is the honest answer: no score exists. The same thinking applies whenever a number is missing: show the gap, don&#8217;t invent a value.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>3. Only the required skills count.<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">I considered a different formula called Jaccard similarity: the overlap divided by all skills from both sides combined. It punishes extra skills. For a job board that felt wrong: a candidate who knows <code>python<\/code>, <code>docker<\/code> and five other things is not a worse fit for a <code>python<\/code> + <code>docker<\/code> job than one who knows exactly two things. My formula asks &#8220;of what this job needs, how much do you have?&#8221;, which is what a company actually wants to know.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>4. No synonyms.<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Right now, <code>k8s<\/code> and <code>kubernetes<\/code> are different skills. Fixing that needs a list of skill names and their aliases, which is a whole data problem of its own. I am leaving it out on purpose: exact, case-insensitive matching is predictable and explainable. A wrong synonym table would quietly change scores and nobody would know why.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>5. A whole number, not decimals.<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A score of 66.67 suggests a precision that doesn&#8217;t really exist. 67 is easier to read, and the rounding rules are simple to explain.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>6. Only the score, no lists.<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The API could also return which skills matched and which are missing. That would be useful in a user interface (&#8220;you are missing: kubernetes&#8221;). But it is also more API surface to design, document and keep consistent. The function already computes <code>matched<\/code> internally, so adding it later is easy. Deferred, not rejected.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">What I learned<\/h2>\n\n\n\n<ul class=\"wp-block-list\"><li>Keep business rules out of the framework. <code>matching.py<\/code> imports nothing from Django. That made it the easiest code in the whole project to test, and it will survive any future refactor of views or serializers.<\/li><li>Pure functions are a gift to your future self. Two lists in, one number out. No setup, no mocks, no database to clean.<\/li><li>Sets are the right tool for &#8220;what do these have in common?&#8221;. Duplicates disappear, and &amp; gives the overlap in one symbol. Using lists and loops for this would have been longer and buggier.<\/li><li>None can be the most honest answer. A fake 100 is worse than &#8220;no score&#8221;.<\/li><li>Calculated vs stored is a real trade-off. Always fresh, but not sortable. Know which one you chose and why.<\/li><li>New fields inherit their object&#8217;s permissions, but test it anyway.<\/li><li>Not every framework name fails silently. <code>get_match_score<\/code> fails loudly if misspelled. Knowing which names shout and which whisper is becoming a theme of this project.<\/li><\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">What comes next<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Both halves of Phase 2&#8217;s goal are now met: wrong user types get 403, and match scores return correctly.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This is Part 6 of my Job Board API series, in Root Access. The story so far: Part 1 built the foundation, Part 2 designed the data model, Part 3 added JWT signup and login, Part 4 fixed validators that never ran, and Part 5 added the permissions.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Next: Part 7, filtering and search. Companies will be able to search jobs and filter applications, and the limitations of a score you cannot sort by will stop being theoretical.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Reza<\/p>\n\n","protected":false},"excerpt":{"rendered":"<p>Where we are In the last part, I added four permission classes to my Job Board API, so every user can only&hellip;<\/p>\n","protected":false},"author":2,"featured_media":238,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_rezaops_part":"6","_rezaops_linkedin_url":"https:\/\/www.linkedin.com\/pulse\/building-job-board-api-part-6-skill-match-score-reza-chegini-iebgc\/","footnotes":""},"categories":[10,116],"tags":[8,111,191,118,192,25,112],"series":[117],"class_list":["post-236","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-devops","category-django","tag-devops","tag-django","tag-drf","tag-job-board","tag-match-score","tag-python","tag-rest-api","series-job-board-api"],"jetpack_featured_media_url":"https:\/\/rezachegini.com\/wp-content\/uploads\/2026\/11\/job-board-api-part-6-cover.png","_links":{"self":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/236","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=236"}],"version-history":[{"count":2,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/236\/revisions"}],"predecessor-version":[{"id":240,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/236\/revisions\/240"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/media\/238"}],"wp:attachment":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=236"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=236"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=236"},{"taxonomy":"series","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fseries&post=236"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}