libcs50: CS50's C library, and a macOS install_name that breaks on upgrade
This is CS50's Library for C.
At a glance
- What is it?
- libcs50 is a GPL-3.0 C library that gives beginners get_int, get_string and friends, built with -Werror -pedantic -std=c11 and installed from a packagecloud repository. The Makefile is careful on Linux and not on macOS: the shared object gets a major-version soname there, while the dylib gets an install name containing the full patch version, so upgrading the library changes what already-linked binaries look for.
- Who is it for?
- Use libcs50 if you are working through the CS50 course material or teaching introductory C and want the get_* prompt-and-retry functions that no libc call provides. Do not adopt it as a general-purpose dependency, because a single-header library with a malloc-returning string type and no documented ownership rules is a teaching convenience rather than infrastructure.
- Can I use it commercially?
- Yes, with conditions. GPL-3.0 is a copyleft licence: if you distribute software that includes it, you must release that software's source code under the same licence. Running it internally without distributing it does not trigger that obligation.
- Is it still maintained?
- Yes. The repository last received commits 19 days ago.
- What is it written in?
- Mainly C, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on September 30, 2026, and from our analysis. They are not legal advice.
Editorial analysis
The macOS install name embeds the patch version
The Makefile branches on the operating system to name its shared library, and the two branches are not equivalent. One of them is right and the other is wrong, in a way that only appears when you upgrade.
On Linux:
ifeq ($(OS),Linux)
LIB_BASE := $(BASENAME).so
LIB_MAJOR := $(BASENAME).so.$(MAJOR_VERSION)
LIB_VERSION := $(BASENAME).so.$(VERSION)
LINKER_FLAGS := -Wl,-soname,$(LIB_MAJOR)The soname is the major version only: libcs50.so.11. That is the correct convention. A soname is the name a dependent binary records and searches for at load time, so it must be stable across patch releases. Upgrading from 11.0.3 to 11.0.4 leaves libcs50.so.11 in place, the soname recorded in every already-linked binary still matches, and nothing needs relinking.
On macOS:
else ifeq ($(OS),Darwin)
LIB_BASE := $(BASENAME).dylib
LIB_MAJOR := $(BASENAME)-$(MAJOR_VERSION).dylib
LIB_VERSION := $(BASENAME)-$(VERSION).dylib
LINKER_FLAGS := -Wl,-install_name,$(LIB_VERSION)The install name is the full version: libcs50-11.0.4.dylib, not libcs50-11.dylib. The Mach-O equivalent of a soname, and the same job, is given the one value that changes on every patch release.
The consequence is specific and reproducible. Compile a program against 11.0.3 and the Mach-O records a load command pointing at libcs50-11.0.3.dylib. Install 11.0.4, which is a different file name, and the old file is gone. The program now fails at launch with a dyld error naming a library that no longer exists, and the fix is to reinstall the old version or relink everything against the new one. The standard remedies are an @rpath-based install name or, at minimum, the major version, and either would make a patch release a non-event on macOS the way it already is on Linux.
The Makefile builds all three names in one rule and creates the symlink chain, so libcs50-11.dylib does exist as a symlink. That does not save the dependent binary, because the loader is following the recorded install name, not resolving it through the symlink.
This is worth flagging because the library is installed with sudo make install into /usr/local, which is the machine-wide location. The consequence is not confined to one user's build; it applies to anything on that machine linked against the previous version.
The troubleshooting guide documents libcs50.so.8
The Troubleshooting section is three numbered items, and it is the best piece of documentation in the README, with one detail that dates it.
Each item maps a specific error message to a specific fix. If compiling produces /usr/bin/ld: cannot find -lcs50, add export LIBRARY_PATH=/usr/local/lib to your .bashrc. If compiling produces fatal error: 'cs50.h' file not found, add export C_INCLUDE_PATH=/usr/local/include. And if executing produces error while loading shared libraries, add export LD_LIBRARY_PATH=/usr/local/lib. Then: close and reopen any terminal windows.
The pattern is exactly right for this audience. The three errors correspond to the three distinct places a /usr/local installation can be invisible: the linker's search path, the preprocessor's include path, and the dynamic loader's search path. A beginner hits all three in sequence, and naming the variable rather than the concept means the fix is copyable. The instruction to close and reopen terminals is the kind of detail that is usually left out and that wastes twenty minutes when it is not.
The date is in the third error message. The library it names is libcs50.so.8. The current version is 11.0.4, so the shared object is libcs50.so.11. The message is three major versions behind, which tells you the section was written for the 8.x line and has not been revisited. It is not harmful, because the structure of the message is identical and the variable is still right, but a reader who copies it into a search box finds documentation for a version that no longer exists, and a reader who is trying to work out which library they have installed gets a contradictory answer.
The related point is the soname discussion above. The third troubleshooting item is only ever about the major-versioned soname, which is why the Linux path works cleanly across upgrades and a user on Linux rarely needs this fix after the first install. The macOS path produces a different situation, because the recorded install name is version-specific, and the troubleshooting section says nothing about the dyld error that a macOS upgrade produces.
The library also installs man pages, with MANDIR defaulting to share/man/man3 and the pages taken from a wildcard over docs/*.3.gz, and the README points at man get_* after installation. That is the right place for the full API, since the usage section in the README is six lines long.
Built at -Werror with -pedantic, and static beside shared in one rule
Two details in the compile flags and one in the build structure say more about how this library is made than the README does.
The flags are CFLAGS=-Wall -Wextra -Werror -pedantic -std=c11. Turning warnings into errors, and asking for pedantic conformance to a specific standard, is not what most introductory teaching libraries do. Most are lenient so that a student's first program compiles. This one refuses to compile a program with a warning, which means the library itself is held to a standard a beginner's code would not meet.
The tension between that and the audience is real and worth naming rather than resolving. For a course, -Werror in the library is fine because the strictness applies to cs50.c and cs50.h, not to the student's code. A student compiles with their own flags. But it also means the library cannot pick up a new compiler's new warning without a code change, which is the cost of the choice and is presumably why the standard is pinned to c11 rather than left to the compiler default.
The build structure is a single rule that produces both library forms. The rule compiles the source twice, once with -fPIC -shared and the platform linker flags to produce the versioned shared object, and once with -c to produce an object file, which ar then archives into libcs50.a. It chmods the static archive to 644, removes the intermediate object, creates the symlink from the base name to the versioned name, and then assembles a build/ tree containing include, lib and src directories before moving the artefacts into it.
So a plain make produces a static archive and a full set of shared objects with the symlink chain, in one pass, and the install target copies the whole build/ tree plus the man pages into DESTDIR, which defaults to /usr/local. There is a separate source Debian package target, which uses fpm and which the Makefile annotates as a temporary fpm source, along with the four Debian maintainer scripts checked into the repository root: post, postinst, postrm and postun. Having the maintainer scripts version-controlled rather than generated is unusual and is the kind of detail that makes a package reproducible.
On Linux the install target also runs ldconfig against the destination lib directory, which is what makes a newly installed library visible to the dynamic loader without a reboot. There is no equivalent step for the Darwin branch, because macOS has no ldconfig, which is consistent with the rest of the platform handling in this Makefile: the Linux side is finished and the macOS side is thinner.
A Travis badge pointing at a branch called master
The first line of the README is a build status badge, and it is wrong in two independent ways.
It points at travis-ci.org, which is the old Travis CI domain, and it passes branch=master as a query parameter. The repository's default branch is main.
Both halves are stale. Travis CI shut down its hosted offering for open source and the travis-ci.org badge service no longer resolves for most repositories, so the badge in the README is an image that either fails to load or is frozen at whatever state it was last in. And the branch it names, master, is not the branch the project builds from.
Neither of these tells you whether the library builds. What they do tell you is that the README has not been maintained with the same care as the Makefile, which is an odd split in a repository where the compile flags are set to -Werror and the packaging includes checked-in Debian maintainer scripts. It is a small project with careful mechanics and an unmaintained front page.
The contrast is sharper when you set it against the rest of the documentation. The Troubleshooting section maps three specific compiler and loader errors to three specific environment variables and tells you to reopen your terminals. The Installation section gives four distinct routes, packagecloud for Ubuntu, the RPM script for Fedora, and a from-source path that explains the DESTDIR override with an example command. And the usage block shows the actual signatures with a comment marking one as deprecated.
Against that, the only status signal in the README is a badge for a service that no longer exists, pointing at a branch that no longer exists. There is no mention of a GitHub Actions workflow in the README, although the repository does have a .github/ directory. So the reader is left with a build status they cannot trust and no alternative offered in the text.
For an evaluator the practical consequence is narrow. You cannot use the badge to decide whether the current commit is good. You have to build it, which takes seconds, because the project is one source file and one header.
VERSION 11.0.4 is typed into the Makefile, and the tag is the same commit
The version is not derived from git in this project. It is a constant at the top of the Makefile, and keeping it in sync with the tag is a manual act.
VERSION := 11.0.4 sits on the first line, and MAJOR_VERSION is computed from it with a shell cut on the dot. Everything else follows: the soname, the dylib names, the symlink chain, the man page directory structure. There is no git2version-style dependency and no tag query, so the artefact and the tag can disagree, and the release history suggests they generally have not.
The three most recent tags are v11.0.2 on 2023-05-02, v11.0.3 on 2024-06-22, and v11.0.4 on 2026-09-11. The last push was on 2026-09-11 at 22:51:07, the same timestamp as the v11.0.4 tag. So the default branch is sitting exactly on the newest release, and the Makefile constant matches it. That is the good case.
The intervals are irregular: about thirteen months, then about twenty-six. A library that exists to make get_int and get_string easier to call has very little to change between years, so an irregular cadence is the expected shape rather than a warning sign. The important thing for an evaluator is that the version scheme is a major version that has been at 11 for the whole visible history, and it is a major version that is used for the Linux soname. So the soname has been stable across every release that exists in the tag list, and the macOS install name has changed on every one.
That asymmetry is worth restating because it is the difference between the two platforms in practice. On Linux, a program compiled against any 11.x release keeps working across a library upgrade. On macOS, it does not. For a library whose entire purpose is to be installed with sudo into /usr/local on a shared machine, that is the difference between a routine upgrade and a broken set of binaries, and it is the one thing in this repository that is an actual defect rather than a stale document.
The README's TODO list has one item, Add tests, and there is a tests/ directory at the top level. Whether that means the item is done or that the directory holds scaffolding is not determinable from what is shown, but the item is still in the README, so treat the test coverage as unestablished.
get_string returns string, and the README never says who frees it
The usage section is the smallest thing in the README and the most consequential, because it is the API a student copies.
#include <cs50.h>
char c = get_char("Prompt: ");
double d = get_double("Prompt: ");
float f = get_float("Prompt: ");
int i = get_int("Prompt: ");
long l = get_long("Prompt: ");
string s = get_string("Prompt: ");Five of the six return a scalar and need no cleanup. The sixth returns a type called string, which is not a C type, so it is a typedef declared in cs50.h, and the C convention for that is a pointer to char. The library therefore hands back allocated memory, and the README's usage block does not say who owns it, does not mention a free function, and does not show one being called.
That omission is the single most important documentation gap in this repository, because it is the one a beginner will hit and cannot reason their way out of. In C, a function returning a pointer to allocated memory transfers or shares ownership depending on convention, and the only reliable signal is documentation. Here the documentation is a man page, which the README does point at with man get_*, and which presumably covers it. But the block in the README, the block people copy, is silent.
The design reason for the type existing at all is that C has no string type, and get_string needs to return a variable-length result, so the library supplies one. That is a reasonable teaching decision. What is questionable is naming a raw pointer string rather than showing the allocation in the example, because a student who reads only the README will write code that leaks on every iteration without ever having been shown the free.
Two smaller observations from the same block. get_long_long is included and marked as deprecated as of fall 2017, which is nine years ago, so it has been on the deprecation list for most of the library's existence under this major version. And the signature style is the point of the whole library: get_int takes a prompt string and returns an int, which no standard library function does. libc gives you scanf, which returns a failure count, or fgets, which returns a buffer, and neither re-prompts on invalid input. Every CS50 exercise depends on the re-prompt, so the library is not a convenience wrapper around scanf, it is a different interaction model.
That is the honest case for and against. As a teaching tool it removes the single most tedious part of an introductory exercise. As a dependency it is a type that shadows a language primitive, with ownership rules that exist only in a man page, and a deprecated function that has not been removed.
What it gives a student, and what it costs outside a course
The real alternative to libcs50 is not another icon library or another wrapper. It is writing six functions yourself, and that comparison is worth making explicitly because it defines the tool's actual value.
A get_int that prompts and re-prompts on invalid input is roughly fifteen lines of C: read a line with getline, convert with strtol, check the end pointer, and loop on failure. Six of those is about a hundred lines. In a course, that hundred lines is the exercise, and a library that supplies it is removing the pedagogical point. Outside a course, those hundred lines are a weekend, and once written they are under your control, they can return a status instead of looping forever, and they do not define a type called string.
So the honest framing is that libcs50 optimises for a specific and legitimate goal, which is getting a beginner past input handling to the assignment the course is actually about. The homepage is the CS50 documentation site and the README points at the CS50 Reference as the authoritative API documentation, which is the correct arrangement: the library is one component of a course and its documentation lives with the course.
The costs outside that frame are concrete rather than aesthetic. A string typedef that shadows nothing in C but reads like a language feature. A deprecated function still in the header. Ownership documented in a man page rather than the example. A static archive and a shared object both installed to /usr/local, so a stale copy can be linked in preference to the current one. And, on macOS, an install name that makes upgrades break.
There is also a distribution dimension. The primary installation path is a packagecloud repository added by piping a shell script into sudo bash, which is a normal and widely used mechanism but is one more thing running as root from a remote URL on a teaching machine. The Fedora path does the same with the RPM variant of the script. The from-source path is four commands and a DESTDIR override, and it is the route to prefer if you are not following the course, partly because you can see the Makefile you are about to run, which is seven lines of environment variables and a few compiler invocations.
Editorial conclusion
Use libcs50 if you are working through the CS50 course material or teaching introductory C and want the get_* prompt-and-retry functions that no libc call provides. Do not adopt it as a general-purpose dependency, because a single-header library with a malloc-returning string type and no documented ownership rules is a teaching convenience rather than infrastructure. Do not deploy it on macOS from source and then upgrade it, because the install name carries the full version and a binary linked against libcs50-11.0.3 will not resolve libcs50-11.0.4. Verify five things. Check whether the third error in the troubleshooting section, which names libcs50.so.8, still describes your install, because the current library is major version 11. Read the man pages with man get_* after installing, since the README's usage block is six lines and the header is where the real API is. Confirm you have the CS50 Reference documentation, which the README links as the authoritative reference rather than the repository. Decide whether you want the static archive as well as the shared object, since the Makefile produces both in one rule. And if you are on macOS, read the linker flags yourself before shipping anything, because that is where the defect is. The deciding fact is that this is a course artefact that has outlived its course, still maintained and still correct on the platform that matters most for its users.
Frequently asked questions
How do I install libcs50 on Ubuntu?
Add the CS50 packagecloud repository with curl -s https://packagecloud.io/install/repositories/cs50/repo/script.deb.sh piped to sudo bash, then run sudo apt-get install libcs50. On Fedora the equivalent is the script.rpm.sh variant followed by yum install libcs50. From source, download a release, extract it, cd into the directory and run sudo make install, with DESTDIR to override the /usr/local default.
How do I link a program against libcs50?
Include the header with #include <cs50.h> and link with -lcs50. If the linker reports cannot find -lcs50, export LIBRARY_PATH=/usr/local/lib; if the preprocessor cannot find cs50.h, export C_INCLUDE_PATH=/usr/local/include; and if the loader cannot open the shared object, export LD_LIBRARY_PATH=/usr/local/lib. The README says to close and reopen terminal windows after setting them.
What functions does the CS50 library provide?
The usage section shows get_char, get_double, get_float, get_int, get_long and get_string, each taking a prompt string. get_long_long is also present and is marked as deprecated as of fall 2017. The full API is in the man pages, readable with man get_* after installation, and the CS50 Reference is linked from the README as the authoritative documentation.
What version is libcs50 at and what licence is it under?
The current version is 11.0.4, tagged 2026-09-11, and it is a GPL-3.0 licensed C library. The version is a constant at the top of the Makefile rather than something derived from git tags, so the tag and the constant have to be kept in sync by hand. The library is built with CFLAGS of -Wall -Wextra -Werror -pedantic -std=c11.
Does libcs50 work on macOS?
The Makefile has a Darwin branch that produces libcs50-11.dylib and libcs50-11.0.4.dylib. However, it sets the Mach-O install name to the full version rather than the major version, so a binary linked against one patch release will not find the next one after an upgrade. The README does not discuss this, and there is no ldconfig-equivalent step in the install target for macOS.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/cs50-libcs50)