LinkedIn Profile Scraper for extracting member names, headlines, locations, companies and follower counts from LinkedIn.com. This repo has a free LinkedIn profile scraping script you can run right now, and a LinkedIn profile data API that returns one member profile as 12 parsed JSON fields.
Last updated: 2026-07-20. Working against LinkedIn.com as of July 2026.
pip install requests
export CHOCODATA_API_KEY="your_key" # free: 1,000 requests, one-time, no card
python linkedin_profile_scraper_api_codes/profile.py williamhgatesThose three lines return this, live from LinkedIn.com (6 of the 12 fields on the profile, verbatim; the full object is below):
{
"name": "Bill Gates",
"headline": "Co-chair",
"location": "Seattle, Washington, United States",
"current_company": "Gates Foundation",
"followers": 40520464,
"connections": null
}...multiplied across a list of members, with 12 fields each:
That is the whole point of this repo. The rest of this page is the free script, the measured evidence behind it, and the full API reference.
This repo covers the member profile surface on its own. The other LinkedIn surfaces, job search, job postings, company pages, posts and public contact fields, are documented in the LinkedIn Scraper.
- Free LinkedIn Profile Scraper
- Avoid getting blocked when scraping LinkedIn profiles
- LinkedIn Profile Scraper API reference
- Enrich a list of LinkedIn profiles into a CSV
- Measured latency
- License
LinkedIn server-renders a schema.org Person block into the logged-out /in/ page, so you can extract a public profile without a headless browser, a login, or JavaScript rendering. No key, no cost:
python free_scraper/linkedin_profile_free_scraper.py williamhgates satyanadella rbransonSource: free_scraper/linkedin_profile_free_scraper.py. It fetches /in/{username}, reads the Person node out of the page's JSON-LD, and emits id, name, headline, location, about, url, image, current_company, followers, companies, schools.
It works, from a cold IP. On 2026-07-20 the first five profiles, requested 4 seconds apart, all carried a full Person block. Here is what the script printed for the first of them, parsed out of 552,194 characters of page markup:
{
"id": "williamhgates",
"name": "Bill Gates",
"headline": "Co-chair",
"location": "Seattle, Washington, United States",
"about": "Chair of the Gates Foundation. Founder of Breakthrough Energy. Co-founder of Microsoft. Voracious reader. Avid traveler. Active blogger.",
"url": "https://www.linkedin.com/in/williamhgates",
"image": "https://media.licdn.com/dms/image/v2/D5603AQF-RYZP55jmXA/profile-displayphoto-shrink_200_200/B56ZRi8g.aGsAY-/0/1736826818802?e=2147483647&v=beta&t=bKWfN6UwwtiCqFWsG7rBELbd48qJOAMLdxhBzzkJV0k",
"current_company": "Gates Foundation",
"followers": 40520408,
"companies": [
"Gates Foundation",
"Breakthrough Energy",
"Microsoft"
],
"schools": [
"Harvard University"
]
}That is a real parse of a real page, and it cost nothing. Then it stops working, and the way it stops is the interesting part. The next section measures it.
LinkedIn did not serve a CAPTCHA, a bot check, or a 403. It served profile after profile, then refused all of them at once, including profiles it had served minutes earlier. Every row below is committed in benchmarks/measurements.json, and benchmarks/measure.py reproduces the whole table on your own IP.
| What we measured (2026-07-20, one residential IP) | Value |
|---|---|
| First 5 profiles, sequential, 4s apart, cold IP | 5 of 5 carried a Person block |
| After 33 profile requests, including a 16-request fan-out at 8 workers | every request refused |
| The refusal | HTTP 999, 1,530 bytes, no Person block |
| A profile that had served 552,194 characters minutes earlier | the same 1,530-byte HTTP 999 |
| A vanity name that does not exist | the same 1,530-byte HTTP 999 |
| Recovery, polled once a minute, 42 times | refused every time, over 42 minutes |
/company/, /jobs/view/ and /jobs-guest/ during that same refusal |
all HTTP 200, full pages |
| Public profile page, when served | 334,394 to 787,580 characters |
| Same profile through the API | 910 bytes of parsed JSON |
Here is the same command from the section above, on the same machine, after the IP crossed the line:
A profile that returned 552,194 characters of markup returned a 1,530-byte shell minutes later, from the same machine, with the same headers, and stayed that way for the rest of the session. That is the whole problem in miniature, and it is the most important thing to understand about scraping LinkedIn profiles at scale:
| What bites you | Why | What it costs you |
|---|---|---|
| HTTP 999 is outside the range clients check | LinkedIn answers a refused caller with 999. requests treats it as success: on the refusal we measured, r.ok was True and raise_for_status() did not fire, because both only test the 400 to 599 range. urllib.request does raise on the same response. |
If you gate on r.ok or raise_for_status(), a refusal reads as a good fetch and 1,530 bytes of tracking JavaScript flows into your parser. Test status_code == 200 explicitly. |
| The refusal is retroactive and sticky | Nothing is refused until everything is. Once the IP crosses the line, profiles that worked minutes ago stop working, and 42 requests at one a minute over 42 minutes did not clear it. | A backfill dies partway through a list, and the obvious fix, slowing down, did not release it inside the 42 minutes we polled. |
| A missing profile and a refused request are the same response | Both return HTTP 999 with a 1,530-byte body. There is no discriminator in the status, the length, or the payload. | You cannot tell "this vanity name is wrong" from "back off" without external state, so your retry logic is guessing on every failure. |
The budget is per surface, and /in/ is the tight one |
While /in/ was refusing every request, the same client on the same IP pulled /company/microsoft (479,268 chars), /jobs/view/ and /jobs-guest/ without a single rejection. |
A crawler that mixes surfaces looks healthy on its own dashboards while the profile half of it silently returns nothing. Monitor per surface, not per host. |
| You get markup, not data | The five profiles in that cold run ran 334,394 to 787,580 characters each. The fields you wanted come back from the API as 910 bytes. | You write and maintain a parser per surface, forever, and redo it every time LinkedIn reshapes the page. |
| A vanity name can resolve to a different member | Asking for sherylsandberg came back describing a different member, three times out of three, with several fields rendered as strings of asterisks. Each of the seven names in profiles_sampled.json resolved to itself. |
Silent identity corruption in an enrichment pipeline. The row comes back populated, plausible, and attached to the wrong person. |
| The markup moves | The Person block's shape and the surrounding sections change without notice. A parser that assumes a key silently returns None. |
Ongoing maintenance, plus alerting smart enough to distinguish "no data" from "broken". |
The mitigation for that last row is cheap and worth wiring in wherever you get your data: compare the profile URL in the response against the vanity name you asked for. profile.py and enrich_profiles.py both do it and flag the mismatch rather than writing the row silently.
One thing worth reading rather than paraphrasing: LinkedIn's robots.txt disallows this. The User-agent: * block, in its entirety, is two lines: User-agent: * followed by Disallow: /. Everything. The other 76 blocks name specific crawlers LinkedIn has chosen to allow on specific paths. The file also states that "the use of robots or other automated means to access LinkedIn without the express permission of LinkedIn is strictly prohibited", pointing at their user agreement and publishing a whitelisting address to request permission (www.linkedin.com/robots.txt, retrieved 2026-07-20).
So the free script is disallowed, and so is the API below. It is your call to make, and worth making with the actual text in front of you rather than discovering it later.
The managed option, and the one this repo is built around. The Chocodata LinkedIn Profile Scraper API returns one member profile as 12 parsed JSON fields, with a ~99% success rate and no proxy management. Free for the first 1,000 requests.
Below is the LinkedIn Profile Scraper API reference to get you started: authentication, the global parameter, error handling, concurrency, and the endpoint.
curl "https://api.chocodata.com/api/v1/linkedin/profile?api_key=YOUR_KEY&username=williamhgates"import requests
r = requests.get(
"https://api.chocodata.com/api/v1/linkedin/profile",
params={"api_key": "YOUR_KEY", "username": "williamhgates"},
timeout=90,
)
p = r.json()
print(p["name"], "|", p["headline"], "|", p["current_company"])
# Bill Gates | Co-chair | Gates FoundationAfter running the command, your terminal should look something like this:
Get a key at chocodata.com (1,000 requests, one-time, no card).
Pass api_key as a query parameter on every request. That is the whole auth model. No OAuth, no LinkedIn login, no session cookie: you never hand us or LinkedIn an account.
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
api_key |
string | yes | - | Your Chocodata API key. The only parameter every endpoint shares. |
Each request costs 5 credits (= 1 request). Responses are billed only on success (2xx).
Real captured error responses, committed in linkedin_profile_scraper_api_data/errors.json. Nothing below is billed: you are only charged on a 2xx.
| Status | error code |
Meaning | Billed | What to do |
|---|---|---|---|---|
400 |
invalid_params |
Neither username nor url was supplied. |
no | Fix the query string. |
401 |
INVALID_API_KEY |
Key missing, unrecognised, or revoked. | no | Check api_key. Get one at chocodata.com. |
402 |
INSUFFICIENT_CREDITS |
Balance exhausted. | no | Top up or upgrade at chocodata.com. |
404 |
item_not_found |
The target returned a 404 for this request. retryable: false. |
no | Fix the input. Retrying will not help. |
429 |
RATE_LIMITED |
Over your plan's concurrency. | no | Back off and retry; see Rate limits. |
502 |
target_unreachable |
LinkedIn refused this request. retryable: true. |
no | Check the vanity name, then retry after a few seconds. |
Two response shapes exist: auth and billing errors nest under error.code (uppercase), while scrape-layer errors are flat with a lowercase error string.
Read 502 on this endpoint as two things at once. Because LinkedIn answers a refused caller and a non-existent vanity name with the identical 1,530-byte HTTP 999 shell, the two are not separable upstream. If a username 502s repeatedly, check the spelling before you build a longer backoff.
The scripts in this repo map every documented status onto an actionable message, so a typo'd key does not hand you a stack trace:
A bad key, verbatim:
curl "https://api.chocodata.com/api/v1/linkedin/profile?api_key=totally_invalid_key_123&username=williamhgates"{"error":{"code":"INVALID_API_KEY","message":"Api key not recognised."}}A missing required param, verbatim. It names the exact requirement:
{"error":"invalid_params","issues":[{"code":"custom","message":"url or username is required","path":[]}]}There is no per-minute request cap. The limit is concurrency: how many requests you may have in flight at once. This is the ceiling the free script hits at 8 workers and never gets past.
| Plan | Concurrent requests |
|---|---|
| Free | 10 |
| Vibe | 30 |
| Pro | 50 |
| Custom | 100 to 500+ |
Exceed it and you get 429, not a queue. The endpoint is a synchronous GET: there is no webhook, callback, or async job to poll. A request can take up to ~5s when LinkedIn forces a re-attempt (see Measured latency), which is why the examples use timeout=90.
Sizing: at Pro (50 concurrent) and the measured 2.5s median, one worker pool sustains roughly 50 / 2.5 = 20 requests/second, so 100,000 profiles is about 83 minutes of saturated pulling. Fan out with a thread pool:
from concurrent.futures import ThreadPoolExecutor
import requests
def one(username):
r = requests.get("https://api.chocodata.com/api/v1/linkedin/profile",
params={"api_key": KEY, "username": username}, timeout=90)
return username, (r.json() if r.ok else None)
people = ["williamhgates", "satyanadella", "rbranson", "melindagates"]
with ThreadPoolExecutor(max_workers=10) as pool: # <= your plan's concurrency
for username, profile in pool.map(one, people):
print(username, profile["current_company"] if profile else "unavailable")One public LinkedIn member profile by vanity name: name, headline, location, about text, profile image, current company, follower count, connection band, and the company and school names from the public preview.
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
username |
string | one of username/url |
- | Vanity name from linkedin.com/in/<username>, e.g. williamhgates. |
url |
string (URL) | one of username/url |
- | Full LinkedIn profile URL. The vanity name is parsed out of it. |
curl "https://api.chocodata.com/api/v1/linkedin/profile?api_key=YOUR_KEY&username=williamhgates"Real response, nothing cut. All 12 fields, 3 experience and 1 education entries, exactly as returned, nulls included (full sample):
{
"id": "williamhgates",
"name": "Bill Gates",
"headline": "Co-chair",
"location": "Seattle, Washington, United States",
"about": "Chair of the Gates Foundation. Founder of Breakthrough Energy. Co-founder of Microsoft. Voracious reader. Avid traveler. Active blogger.",
"url": "https://www.linkedin.com/in/williamhgates",
"image": "https://media.licdn.com/dms/image/v2/D5603AQF-RYZP55jmXA/profile-displayphoto-shrink_200_200/B56ZRi8g.aGsAY-/0/1736826818802?e=2147483647&v=beta&t=bKWfN6UwwtiCqFWsG7rBELbd48qJOAMLdxhBzzkJV0k",
"current_company": "Gates Foundation",
"followers": 40520464,
"connections": null,
"experience": [
{
"title": null,
"company": "Gates Foundation",
"date_range": null,
"location": null
},
{
"title": null,
"company": "Breakthrough Energy",
"date_range": null,
"location": null
},
{
"title": null,
"company": "Microsoft",
"date_range": null,
"location": null
}
],
"education": [
{
"school": "Harvard University",
"degree": null,
"date_range": null
}
]
}Running it:
current_company and followers are the pair worth having: a member resolved to a live employer with an audience size attached, which is what makes this usable as an enrichment step. headline is the member's own one-line self-description and is the field most people key their segmentation off.
Five things to know before you build on this. The field-population counts come from the seven profiles in profiles_sampled.json:
experience[]gives you company names.title,date_rangeandlocationwerenullon all 25 experience entries sampled, andeducation[].degreeandeducation[].date_rangewerenullon all 29 education entries. What you get from these two arrays is the list of company names and school names. Code against the nulls.connectionsis a band, not a number, and is often absent. It came back as the string500+on 4 of 7 profiles andnullon the other 3.followersis the reliable audience field: it was populated on 7 of 7 and is an exact integer.followersmoves between calls. Bill Gates went from 40,520,405 to 40,520,483 over the session. It is a live counter, so treat it as a reading with a timestamp rather than a stable attribute.- Check
urlagainst the name you asked for. The response reports the profile it actually landed on, and that is not always the one you requested: see the last row of the table above. A one-line comparison turns a silent bad row into a flagged one, and both scripts here do it. locationis free text as the member wrote it.Seattle, Washington, United Stateson one profile,United Stateson another. Do not parse it into fixed columns; it is not a structured address.
Runnable: linkedin_profile_scraper_api_codes/profile.py
Turning a list of profile URLs into a table with employer and audience size attached is the main reason people pull single profiles, so that use case is in the repo end to end rather than as a snippet. enrich_profiles.py resolves every name concurrently, writes a flat CSV, and flags any row where the profile it landed on is not the one you asked for:
python linkedin_profile_scraper_api_codes/enrich_profiles.py williamhgates satyanadella rbranson melindagates reidhoffmanThat is a real run: five members resolved at 5 concurrent in 6.9 seconds. Feed it a file instead when the list gets long:
python linkedin_profile_scraper_api_codes/enrich_profiles.py --file people.txt --out people.csv --workers 8The CSV carries input, name, headline, location, current_company, followers, connections, url, slug_matches_input, companies, schools and status, so a failed row stays in the file with a reason attached instead of vanishing. Keep --workers at or below your plan's concurrency.
Do this arithmetic before you start: one request per profile, so a 1,000-row list is 1,000 requests and consumes the entire one-time free allowance in a single run. Re-running the same list monthly to refresh employers costs the same again each time.
Real end-to-end wall clock, measured from a laptop against the live API on 2026-07-20 by benchmarks/measure.py. This includes the upstream fetch and the parse:
| Endpoint | Median | Range | n |
|---|---|---|---|
/linkedin/profile |
2.5s | 2.2 to 4.9s | 7 |
Read the range, not just the median. The 4.9s is the interesting number: that is a request that ran into a refusal upstream and was re-attempted until real data came back. Absorbing that, silently, is a good part of what you are paying for. Small sample, one machine, one day: run benchmarks/measure.py and use your own numbers.
MIT. See LICENSE.






