Reading
A reading list from the RSS feeds I follow (happy to get good recommendations)
See my feeds.yaml.
Go blog
- Arch-specific SIMD in GoOct 02, 2026
Go 1.27 introduces experimental arch-specific SIMD vector APIs
- Platform-independent SIMD in GoSep 24, 2026
Go 1.27 adds an experimental platform-agnostic SIMD API
- Size-Specialized Memory AllocationSep 16, 2026
Go 1.27 improves performance of small allocations using size-specialized allocation functions.
- Goroutine Leak ProfilesSep 02, 2026
Go 1.27 includes new goroutine leak profiles.
- Generic MethodsAug 26, 2026
Go 1.27 adds generic methods—a highly desired language feature.
- Go 1.27 is releasedAug 19, 2026
Go 1.27 adds generic methods, encoding/json/v2 package, uuid package, faster memory allocation, goroutine leak profiles, and more.
- Introducing the pkg.go.dev APIMay 21, 2026
Introducing the new programmatic API for pkg.go.dev, allowing developers to fetch package and module data directly.
- Type Construction and Cycle DetectionMar 24, 2026
Go 1.26 simplifies type construction and enhances cycle detection for certain kinds of recursive types.
- //go:fix inline and the source-level inlinerMar 10, 2026
How Go 1.26's source-level inliner works, and how it can help you with self-service API migrations.
- Allocating on the StackFeb 27, 2026
A description of some of the recent changes to do allocations on the stack instead of the heap.
Eli Bendersky
- Summary of reading: July - September 2026Sep 30, 2026
"Wuthering Heights" by Emily Brontë - good writing, but the protagonists are quite something. There's barely a likable character in the whole story - they are all either crazy, evil, stupid or a combination of these. It's also remarkable to consider how small the world of some people in those times was …
- Rusty thoughts on "Parse, don't validate"Sep 26, 2026
Like many programmers, I find Alexis King's Parse, don't validate article fascinating, because it gives a name to an idiom that seems familiar and important - one I've observed and used in the past without naming it explicitly. This post is a review of the "Parse, don't validate" pattern applied to …
- Notes on discrete-time Fourier series and transformSep 19, 2026
The following are my notes on discrete-time Fourier series (DTFS), as well as the discrete-time Fourier transform (DTFT). These topics serve as an important theoretical underpinning to the digital processing of signals by computers using the DFT (which will be covered in a future post). For discrete-time signals, we use …
- How big are factorials?Aug 27, 2026
The other day, I found myself wondering how big 52! (52 factorial) is, and that led me to ponder how these could be estimated without a calculator or a computer. It turns out there’s some fairly interesting math behind being able to estimate the size (number of digits) of …
- Concurrent Servers: Part 8 - GoAug 22, 2026
This is part 8 in a series of posts on writing concurrent network servers. In this part, we'll switch to Go and see how it tackles the challenges described earlier in the series. All posts in the series: Part 1 - Introduction Part 2 - Threads Part 3 - Event-driven Part 4 - libuv …
Julia Evans
- Replacing the old battery on rechargeable bike lightsSep 27, 2026
Hello! Recently I needed bike lights for my bike. And I remembered that I already had rechargeable bike lights that I bought ten years ago, that I hadn’t tried in a long time. I tried to recharge them, but after fully charging them, they only worked for maybe 5 minutes before they turned off again. I don’t know much about electronics, but I’ve been curious about whether it’s possible to fix old electronics for a long time, and this seemed like the perfect repair project because I might just need to replace the battery. So I went to the local queer makerspace where I’m a member to use the soldering iron and try to do it! I don’t know much about electronics and this post does not contain any safety advice because I don’t know much about safety. I think it’s nice to do projects in a community space where you can get help. step 1: cut it open The bike light felt like it was made of silicone, so I cut open the silicone in a haphazard way along something that vaguely looked like a seam. I definitely ripped some silicone in the process and it was pretty messy but I got it open and found the circuit board. I don’t know the model number of the bike lights but there’s a photo of them at the end of the post. step 2: remove the screws There were some screws attaching things together so I removed them so I could get the circuit board out. Mostly I tried to remove as few screws as possible because I was worried about losing them or not being able to put them back after. I probably put the screws in a bag or something. step 3: get the circuit board out I took out the circuit board. Here’s what it looked like: You can see where the battery is attached, I think it’s left of RI3 and above Q2. Here’s what the battery looked like: step 4: desolder the battery I’d never desoldered anything before, so I found the iFixit guide to desoldering and read it. Also I asked my friends Lee and Lauria for advice. Here were the steps I ended up following based on the guide & the advice I got: Use a desoldering pump to remove most of the solder Once most of it is gone, kind of pull them apart to try to separate them Also try to avoid getting the battery too hot in the process by taking breaks to let it cool down. I’m not very good with a soldering iron so it took a while. The battery has an attachment that is welded to the top. For a while I thought I needed to remove this and it seemed impossible, but it turned out the replacement battery comes with that part so actually I was supposed to leave it alone. step 5: identify the battery In the picture of the battery in Step 3, you can see it says something like “3” and “LI???77”. There’s a piece of metal that I think is welded or something to the top of the battery. It seemed impossible and also maybe not smart to try to remove so I wasn’t sure how to find out what an “LI????77” was or how to order another one. I’ve been trying to avoid using LLMs (though I will not get into that because I am exhausted by LLM discourse and I’m sure you are too), but I really had no idea how to figure out what the battery was so I asked an LLM. It gave the response “LIR2477”, which (when I looked it up) looked exactly the same as my battery so I figured that was plausible. I would be interested to learn non-LLM ways to figure this out though. There must be a way. Lauria showed me how to use DigiKey’s search which was very cool though DigiKey didn’t have that part. (edit: someone in the replies told me that this kind of coin cell battery is named according to its dimensions, and you can use plastic calipers to measure the dimensions of the battery. So I guess a non-LLM way would be to measure the battery with calipers and try to match it to something on the List of battery sizes Wikipedia page, though that page only mentions CR 2477 and not LIR 2477. It’s a good example of what’s fun for me about trying to avoid LLMs, this “List of battery sizes” page is super interesting and if I use an LLM I might never find it) step 6: buy the battery I went to AliExpress and ordered: 2 batteries (I had 2 bike lights and I wanted to fix them both) some silicone glue to glue things back together I think the batteries were $3 each and the glue was $8. step 7: solder the new batteries in and glue it back together The parts took maybe 2 weeks to arive, and once they arrived, I went back to the makerspace and: soldered in the new batteries put the screws back in. The screws were very small and hard to hold, so at this point I dropped some screws on the ground and couldn’t find them because they were too small. So I just used fewer screws and hoped for the best. used the glue to try to put everything back together. Make a somewhat halfhearted attempt to clamp the parts I was gluing together Then after waiting some amount of time for the glue to dry I took it home and waited 24 hours for the glue to cure. Also I took the old batteries to somewhere nearby that accepts old batteries. it works! The lights work! I have used them to bike at night! I still haven’t needed to recharge them (and tragically I had to order a new Mini USB cable because I got rid of all my Mini USB cables, so I’m still waiting for that), so I still don’t know for sure how long the lifetime of the new battery will be. Here’s what the light looks like after re-gluing. You can see that I didn’t glue very carefully. It didn’t really go back together that well but I’m hoping it’ll be good enough. I thought it was really cool that I was able to do this with extremely minimal electronics skills! It cost about $20 CAD to buy the parts, and (whether or not the repair holds up, I’ll try to update this post in the future!), it was fun to try to repair something and learn something new.
- Some more things about Django I've been enjoyingJul 21, 2026
Hello! I’m on a funny journey right now where I’m trying to learn how to make websites in a sort of 2010 style, where I have an SQL database and render some HTML on the backend. It’s kind of an interesting journey because it doesn’t necessarily feel “easy” to me to make websites in this way: I never learned how to do it in the 2000s or 2010s, and there’s a lot I need to learn. So here are some Django features that make building this kind of site feel more achievable than when I was trying and failing to use Go’s standard library or Flask. And I’ll talk about a couple of issues with Django I’ve run into. why learn to make websites like it’s 2010? Previously the toolkit I felt confident with for making websites was: static site generators (like for this blog) static sites that do some fun stuff with Javascript (like this sql playground ) simple Vue.js single page apps with either a Lambda as a backend or a Go backend (like mess with dns ) I really liked this frontend-heavy approach for these super simple applications but when I started thinking about making something with a lot of different pages (instead of literally just one page), I didn’t feel so excited about the options I saw that involved a lot of frontend code. So I figured I’d try the backend. Writing a backend-focused site that uses as little JS as possible feels the same to me in a way as writing a single-page JS website that does as little on the backend as possible, even though they might seem like opposites. In both cases I’m just trying to keep as much of the logic as possible in one place. Now for some thoughts about Django! I’m enjoying query builders I learned that I can define a “query set” class in Django with a bunch of methods with different WHERE statements I might want to use while constructing a query: Here’s how I use it in my view code once I’ve defined what all the methods mean: Events.objects.approved() .for_tab(tab) .with_festivals(tab_params.festival_slugs) .is_free(tab_params.free) .is_outdoors(tab_params.outdoors) and here’s how I define the methods: class EventQuerySet(SearchableQuerySetMixin, models.QuerySet): def approved(self): return self.filter(approved_at__isnull=False) def future(self): today = timezone.localdate() return self.filter(end__gt=self._midnight(today)) def with_tags(self, tags): if tags: return self.filter(tags__name__in=tags).distinct() return self The syntax for defining the filters isn’t my favourite, but I spend most of my time just using the methods, and it feels super readable and nice to use, and it makes me want to look into other query builder libraries in the future. In the past I thought “I know SQL, who needs a query builder?”, but this kind of structure does make it really nice to read. I found an example of someone who wrote their own small query builder in Python that I want to read later to think about whether I would enjoy using a more minimal version of this. the template filters are awesome There are a bunch of little quality of life filters available in Django templates that are super useful for generating HTML. The ones I’ve used so far are: translating plain text URLs into links, or line breaks into <br> ( {{ event.description|urlize|linebreaksbr }} ) formatting dates ( {{ row.date|date:"M j" }} ) json_script , which takes a Python dictionary and automatically converts it to JSON and inserts it into the HTML as a <script> tag in a safe way These are all small things individually but I feel like it makes a big difference somehow to just have them available. querystring is cool I think my favourite template filter is querystring : in this site sometimes we use filters like ?date=2026-06-01 to decide what’s displayed. querystring that will make a link to the same query string with one change, like this to link to the previous date: <a href="{% querystring date=nav.prev_date%}"> Or to remove the outdoors parameter: <a href="{% querystring outdoors=None %}"> automatic database migrations are still great I still really love Django’s automatic database system. It’s amazing to be able to just edit a model to add a new field or whatever, and then Django automatically generates the migration. So far we have done 19 database migrations and I think there will probably be more! It makes a huge difference for me to be able to just easily change the database as my understanding of the problem changes. I do not want to organize my code with inheritance Django’s documentation sometimes offers the option of using class-based views and inheritance to organize the code in your views. For example I have four views that share a lot of code, and I could use inheritance to manage that by defining some kind of parent class and then having my other views inherit from it. I tried it out and I did not enjoy the experience of using inheritance to share code between views. I switched to using functions instead, sort of how this post advocates, and that was a lot more straightforward. I’ve never had a good experience using inheritance in Python and I don’t think I’ll try to use it again. But I don’t mind using inheritance to use the interfaces Django itself provides: for example if I want to define a query set I need to write something like class EventQuerySet(SearchableQuerySetMixin, models.QuerySet) . I don’t think too hard about it and it seems to work. (as a meta comment: I’ve been working on talking about my programming opinions by just saying “THING does not feel good to me, I prefer OTHER THING instead”. That post I linked to says that function-based views are the “right way”. I’m not very invested in whether it’s “right”, but it’s validating to know that other people feel similarly to me about inheritance) I don’t know how to think about Django performance At some point the LLM scrapers discovered our site, and started sending us maybe 10 requests per second. I blocked them which is working for now, but it made me think about what the site’s capacity is. I’m used to writing Go backends where the performance situation is pretty straightforward (usually everything is just fast enough), and a Django site is very different. Some light load testing (with ( ab -n 1000 -c 1 ) shows that right now we can serve about 2-3 requests per second (on a ~$10/month VM). It’s tempting for me to go down a rabbit hole where I do a bunch of profiling to figure out what’s slow and try to make it faster (there’s py-spy for that, and py-spy is great and super easy to use, and profiling is fun!) But I really don’t understand what I should expect in terms of performance from a Django site and how I should be thinking about at a higher level. Some things I haven’t figured out yet: If I have a site that’s going to be getting occasional bursts of traffic, do I want to be able to scale up? Do I want to design the site so that more things can be cached? (and do I really have to? caches are so annoying to get right!) The django performance docs say that Jinja is faster for templating, do I want to think about switching templating systems? Those docs also say “{% block %} is faster than using {% include %}”, I wonder if it’s a big difference and if so why template caching might be important I think one thing I’m learning about Django is that because it’s a Framework (tm), it’s easy to accidentally misconfigure it. For example, when I was thinking about why my site was slow just now, I read the django performance docs and I noticed a comment saying: Enabling the cached template loader often improves performance drastically, as it avoids compiling each template every time it needs to be rendered. When I’d done CPU profiling I’d noticed that it was spending a lot of time rendering templates! Maybe this could help me! Clicking through the link, I saw that the cached template loader was supposed to be on by default, but I’d turned it off by accident while trying to do something else. I think this “I turned off the cached template loader by default” things is an example of how I still find the django settings file to be pretty confusing and difficult. I guess I should just be careful when I go in there. After turning on template caching, it seems like the site can now pretty easily handle 12 requests per second or so without using all of the CPU. I have not carefully benchmarked the before and after but it seems like it’s made a pretty big difference. One thing that’s been surprising to me about Django performance is that I’ve always heard the advice “if you have a performance problem, check your database queries! Maybe add an index!”. But I’ve been running into a variety of performance issues (like this template caching thing) that are not because of slow queries, so instead it’s been more useful for me so far to start by running a CPU profile. And since I’m using SQLite, any slow database query problem will show up on the CPU profile anyway. Anyway I don’t want to get too far into site performance. Like I said it’s easy for me to get interested in profiling, but actually I know a lot about profiling and it’s not the most important thing for me to learn about. that’s all for now! I might say more about what I’m enjoying (or having a hard time with!) about Django later. Trying to write some shorter blog posts recently.
- Learning a few things about running SQLiteJul 17, 2026
Hello! I’ve been working on a Django site recently, and I decided to use SQLite as the database. When I was getting started with using SQLite as database for a website I read a bunch of blog posts about how it is totally fine to use SQLite in production for a small site and I think it is totally fine, but what I did not fully appreciate is that SQLite is still a database, databases are complicated, and I do not know a lot about operating databases. So here are a couple of small things I’ve been learning about running SQLite. This is the 4th website I’ve used SQLite for, and I think this one is harder because with the power of the Django ORM I’ve been making the database do more work than I was previously without Django. I started by turning on WAL mode like all the blog posts said to do and hoping for the best. ANALYZE is apparently important Today I was running a query (using SQLite’s FTS5 for full-text search) on a table with 4000 rows and it took 5 seconds. That seemed wrong to me: computers are fast! It turned out that what I needed to do was to run ANALYZE ! Immediately the problem query went from taking 5 seconds to like 0.05 seconds (or some other number small enough that I didn’t care to investigate further). I still don’t know exactly what went wrong in the query plan, but my best guess is that it was some sort of accidentally quadratic thing. ANALYZE generates “statistics” (I guess about the number of rows in each table? and presumably other things?) so that the query planner can make better choices. Maybe one day I’ll learn to read a query plan. cleaning up the database is tricky Occasionally I’ve run into situations where I accidentally put a bunch of rows in my database that I don’t want to be there (for example completed tasks from django-tasks-db ), and I want to clean them up. What’s happened to me a few times in this case is: I run some kind of command to clean up the rows The command takes more than 5 seconds, since there are a lot of rows (though I still have some questions about why these DELETE statements are so slow honestly, maybe there’s a bunch of Python code running inside a transaction, I’m not sure) One of the other workers tries to write the database while this is happening, and times out after 5 seconds (I have a timeout of 5 seconds set) The worker crashes because it couldn’t write to the database and the VM shuts down My approach so far has been to just do these cleanup operations in small batches so that I don’t need to do database queries that take more than 5 seconds to run. This whole experience has given me more of an appreciation for why someone might want to use a “real” database like Postgres which can have more than one writer at the same time though. Maybe in the future I’ll just take the site down for scheduled maintenance instead when I need to do this kind of thing, but I haven’t figured out a workflow for that yet. no notes on performance of ORM queries yet So far I’ve been using Django’s ORM to make any query I want without paying any attention at all to query performance and it’s mostly been going okay other than the ANALYZE thing. The database is pretty small (maybe 10000 rows?) and I expect it to stay pretty small forever, so I’m hoping that that plan will keep working. backing up sqlite I’ve done SQLite backups a couple of ways. I don’t think I’ve actually tested restoring from my backups but I do usually try to monitor them with a dead man’s switch. way 1: restic sqlite3 /data/calendar.db "VACUUM INTO '/tmp/calendar.sqlite'" gzip /tmp/calendar.sqlite # Upload backup to S3 # Sometimes the backup gets OOM killed and so it stays locked, do an unlock restic -r s3://s3.amazonaws.com/some_bucket/ unlock # Do the backup & prune old backups restic -r s3://s3.amazonaws.com/some_bucket/ backup /tmp/calendar.sqlite.gz restic -r s3://s3.amazonaws.com/some_bucket/ snapshots restic -r s3://s3.amazonaws.com/some_bucket/ forget -l 1 -H 6 -d 2 -w 2 -m 2 -y 2 restic -r s3://s3.amazonaws.com/some_bucket/ prune way 2: litestream I started trying out Litestream recently because I felt like doing incremental backups might be more efficient: my restic backups were sometimes getting OOM killed, and I was a bit tired of it. Basically I just write a config file and run: litestream replicate -config litestream.yml I set retention: 400h in my config file in an attempt to retain some amount of history of the database but I have no idea if it works. I’ve been backing up to AWS, which is always a pain because it’s annoying to navigate the AWS console to generate credentials. Maybe one day I’ll move away to some other S3-compatible alternative. you can use multiple databases My current project only has one database, but one trick I used with Mess with DNS was to split the tables into three separate database files because I didn’t actually need my tables to be in the same db. I think it was helpful. Mess with DNS has been running on SQLite for 4 years now (since 2022) and it’s been great, I think the move from Postgres was a great choice for that project. that’s all! It’s always kind of fun to see how long it takes me to learn sort of basic things about the technologies I’m using. I think I used SQLite for a web project for the first time in 2022 and I only learned that ANALYZE existed today! I imagine in a year or two I’ll be learning about some other very basic feature. some references Some blog posts I’ve looked at, other than the official docs: The definitive guide to using Django with SQLite in production a gist on sqlite performance tuning
- Moving away from Tailwind, and learning to structure my CSSMay 15, 2026
Hello! 8 years ago, I wrote excitedly about discovering Tailwind . At that time I really had no idea how to structure my CSS code and given the choice between a pile of complete chaos and Tailwind, I was really happy to choose Tailwind. It helped me make a lot of tiny sites! I spent the last week or so migrating a couple of sites away from Tailwind and towards more semantic HTML + vanilla CSS, and it was SO fun and SO interesting, so here are some things I learned! As usual I’m not a full-time frontend developer and so all of my CSS learning has happened in fits and starts over many years. it turns out Tailwind taught me a lot When I started thinking about structuring CSS, I was intimidated at first: I’m not very good at structuring my CSS! But then I started reading blog posts talking about how to structure CSS (like A whole cascade of layers or How I write CSS in 2024 ) and I realized a couple of things: Every CSS code base has a bunch of different things going on (layouts! fonts! colours! common components!) It’s extremely useful to have systems or guidelines to manage each of those things, otherwise things descend into chaos Tailwind has systems for some of these, and I already know those systems! Maybe I can imitate the systems I like! For example, Tailwind has: a reset stylesheet a colour palette a font scale the systems I’m going to talk about I’m going to talk about a few aspects of my CSS codebase and my thoughts so far what kind of rules I want to impose on the codebase for each one. Some of them are copied from Tailwind and some aren’t. reset components colours font sizes utility classes the base spacing responsive design the build system 1. reset I just copied Tailwind’s “ preflight styles ” by going into tailwind.css and copying the first 200 lines or so. I noticed that I’ve developed a relationship with Tailwind’s CSS reset over time, for example Tailwind sets box-sizing: border-box on every element (which means that an element’s width includes its padding and border): * { box-sizing: border-box; } I think it would be a real adjustment for me to switch to writing CSS without these, and I’m sure there are lots of other things in the Tailwind reset (like html {line-height: 1.5;} ) that I’m subconsciously used to and don’t even realize are there. 2. components This next part is the bulk of the CSS! The idea here is to organize CSS by “components”, in a way that’s spiritually related to Vue or React components. (though there might not actually be any Javascript at all in the site) Basically the idea is that: Each “component” has a unique class The CSS for one component never overrides the CSS for any other component Each component has its own CSS file So editing the CSS for one component won’t mysteriously break something in another component. And probably like 80% of the CSS that I would actually want to change is in various component files, so if I’m editing a 100-line component, I just have to think about those 100 lines. It’s way easier for me to think about. For example, this HTML might be the .zine “component”. <figure class="zine horizontal"> <img src="whatever.jpg"> </figure> And the CSS looks something like this, using nested selectors: .zine { ... &.horizontal { ... } &.vertical { ... } &:hover { ... } } I haven’t done anything programmatic (like web components or @scope ) that ensures that components won’t interfere with each other, but just having a convention and trying my best already feels like a big improvement. Next: conventions to maintain some consistency across the site and keep these components in line with each other! 3. colours colours.css has a bunch of variables like this which I can use as necessary. Colour is really hard and I didn’t want to revisit my use of colour in this refactor, so I left this alone. The only guideline I’m trying to enforce here is that all colours used in the site are listed in this file. :root { --pink: #fea0c2; --pink-light: #F9B9B9; --red: #f91a55; --orange: rgb(222, 117, 31); ... } 4. font sizes One thing I appreciated about Tailwind was that if I wanted to set a font size, I could just think “hm, I want the text to be big”, write text-lg , and be done with it! And maybe if it’s not big enough I’d use xl or 2xl instead. No trying to remember whether I’m using em or px or rem . So I defined a bunch of variables, taken from Tailwind, like this: --size-xs: 0.75rem; --line-height-xs: 1rem; --size-sm: 0.875rem; --line-height-sm: 1.25rem; Then if I want to set a font size, I can do it like this. It’s a little more verbose than Tailwind but I’m happy with it for now. h3 { font-size: var(--size-lg); line-height: var(--line-height-lg); } 5. utilities There are some things like buttons that appear in many different components. I’m calling these “utilities”. I copied some utility classes from Tailwind (like .sr-only for things that should only appear for screenreader users). This section is pretty small and I try to be careful about making changes here. 6. the base “base” styles are styles that apply across the whole site that I chose myself. I have to keep this section really small because I’m not confident enough to enforce a lot of styles across the whole site. These are the only two I feel okay about right now, and I might change the <section> one: /* put a 950px column in the middle of each <section> */ section { --inner-width: 950px; padding: 3rem max(1rem, (100% - var(--inner-width))/2); } a { color: var(--orange); } I think for the base styles it’s going to be easiest for me to work kind of bottom up – first start with almost nothing in the base styles, and then move some styles from the components into base styles as I identify common things I want. 7. spacing I haven’t completely worked out an approach to managing padding and margins yet. I’m definitely trying to be more principled than how I was doing it in Tailwind though, where I would just haphazardly put padding and margins everywhere until it looked the way I wanted. Right now I’m working towards making the outer layout components in charge of spacing as much as possible. For example if I have a <section> with a bunch of children that I want to have space between them, I might use this to space the children evenly: section > *+* { margin-top: 1rem; } Some inspiration blog posts: the owl selector “no outer margin” 8. responsive design: use more grid! The way I was doing responsive design in Tailwind was to use a lot of media queries. Tailwind has this md:text-xl syntax that means “apply the text-xl style at sizes md or larger”. I’m trying something pretty different now, which is to make more flexible CSS grid layouts that don’t need as many breakpoints. This is hard but it’s really interesting to learn about what’s possible with grid, and it’s a good example of something that I don’t think is possible with Tailwind. For example, I’ve been learning about how to use auto-fit to automatically use 2 columns on a big screen and 1 column on a small screen like this: display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 400px), max-content)); justify-content: center; I also used grid-template-areas a lot which is an amazing feature that I don’t think you can use with Tailwind. Some inspiration: A responsive grid layout with no media queries from CSS Tricks 9. the build system: esbuild In development, I don’t need a build system: CSS now has both built in import statements, like this: @import "reset.css"; @import "typography.css"; @import "colors.css"; and built in nested selectors, like this: .page { h2 { ...} } If I want, I can use esbuild to bundle the CSS file for production. That looks something like this. esbuild style.css --bundle --loader:.svg=dataurl --loader:.woff2=file --outfile=/tmp/out.css Even though I usually avoid using CSS and JS build systems, I don’t mind using esbuild (which I wrote about in 2021 here ) because it’s based on web standards and because it’s a static Go binary. why migrate away from Tailwind? A few people asked why I was migrating away from Tailwind. A few factors that contributed are: Tailwind has become much more reliant on a build system since 2018, I think it’s impossible (?) to use newer versions of Tailwind without using a build system. So I’ve been using Tailwind v2 for years. (there’s also litewind apparently) It’s always been true that you’re supposed to use Tailwind with a build system, but I’ve never really done that, so I have 2.8MB tailwind.min.css files (270K gzipped) in a lot of my projects and it feels a little silly. I’m a lot better at CSS than I was when I started using Tailwind Ultimately Tailwind is limiting: if you want to do Weird Stuff in your CSS, it’s not always possible with Tailwind. Those limits can be extremely useful (a lot of this post is about me reimplementing some of Tailwind’s limits!) but at this point I’d like to be able to pick and choose. I ended up with sites that mixed both vanilla CSS and Tailwind in the same project and that was not fun to maintain I got curious about what writing more semantic HTML would feel like. CSS features I’m curious about While doing this I learned about a lot of CSS features that I didn’t use but am curious about learning about one day: @layer (from A Whole Cascade of Layers ) @scope ) (especially this example of how to use @scope in a “component” CSS design from the specification! ) container queries subgrid one last reason I moved away from Tailwind I’ve been talking a lot in this post about what I learned from using Tailwind, and that’s all true. But I read this post 3 years ago called Tailwind and the Femininity of CSS that really stuck with me. I honestly probably started out with an attitude towards CSS a little like that post describes: They’ve heard it’s simple, so they assume it’s easy. But then when they try to use it, it doesn’t work. It must be the fault of the language, because they know that they are smart, and this is supposed to be easy. But in the last 10 years I’ve learned to really love and respect CSS as a technology. So I decided years ago that I wanted to react to “CSS is hard” by getting better at CSS and taking it seriously as a technology, instead of devaluing it. Doing that changed everything for me: I learned that so many of my frustrations (“centering is impossible”) had been addressed in CSS a long time ago, and that also what “centering” means is not always straightforward and it makes sense that there are many ways to do it. CSS is hard because it’s solving a hard problem! I’ve been so impressed by the new CSS features that have been built in the last 10-15 years (some of which I’ve talked about in this post!) and how they make it easier to use CSS, and spending the time to improve my CSS skills has been a really cool experience. And that post made me feel like Tailwind contributes to the devaluing of CSS expertise, and like that’s not something I want to be a part of, even if Tailwind has been a useful tool for me personally. Especially in this time of LLMs where it feels more important than ever to value humans’ expertise. Another blog post criticizing Tailwind that influenced me: Classic rock, Mario Kart, and why we can’t agree on Tailwind that’s all for now! Thanks to Melody Starling who originally designed and wrote the CSS for wizardzines.com , everything cool and fun about the site is thanks to Melody. Also I read so many incredible blog posts about CSS while working on this (from CSS Tricks , Smashing Magazine , and more), I’ve tried to link some of them throughout this post and I really appreciate how much folks in the CSS community share their practices.
- Links to CSS colour palettesMay 04, 2026
A while back I decided to stop using Tailwind for new projects and to just write vanilla CSS instead. But one thing I missed about Tailwind was the colour palette ( here as CSS ). If I wanted a light blue I could just use blue-100 and if I didn’t like it maybe try blue-200 or blue-50 . I’m not very good with colours so it makes a big difference to me to have a reasonable colour palette that somebody who is better at colour than me has thought about. But I’m also a little tired of those Tailwind colours, so I asked on Mastodon today what other colour palettes were out there. And then a friend said they wanted links to those colour palettes, so here’s a blog post so my friend can see them, and all the rest of you too :) my favourites The ones I liked the most were: uchū ( css file , FAQ ) flexoki ( css file ) reasonable colours , which seems to have a focus on accessibility ( css file ) more colour palettes web awesome radix US web design systems material design colourscheme generators Folks also linked to a bunch of colour palette generators harmonizer tints.dev coolors colorpalette.pro I’ve always found these types of generators too hard to use but maybe one day I will get better enough at colour that I’m able to use a colour palette generator successfully so I’ll leave those links there anyway. and more colour tools: colorhexa has some info about colorblindness oklch Generative colors with CSS gives an example of how to use the oklch CSS function to dynamically generate colors.
- Testing Vue components in the browserMay 02, 2026
Hello! One of my long term projects on here is figuring out how to write frontend Javascript without using Node or any other server JS runtime. One issue I run into a lot in my frontend JS projects is that I don’t know how to write tests for them. I’ve tried to use Playwright in the past, but it felt slow and unwieldy to be starting these new browser processes all the time, and it involved some Node code to orchestrate the tests. The result is that I just don’t test my frontend code which doesn’t feel great. Usually I don’t update my projects much either so it doesn’t come up that much, but it would be nice to be able to make changes with more confidence! So a way to do frontend testing that I like has been on my wishlist for a long time. idea: just run the tests in the browser tab Alex Chan wrote a great post a while back called Testing JavaScript without a (third-party) framework in response to one of my previous posts in this series that explained how to write a tiny unit-testing framework that runs in a page in browser. I loved this post at the time, but it only talked about unit testing and I wanted to write end-to-end integration tests for my Vue components, and I didn’t know how to do that. So when I was talking to Marco the other day and he said something like “you know, you can just run tests for your Vue components in the browser”, I thought “hey, I should try that again!!!” I just did all of this yesterday so certainly there’s a lot to improve but I wanted to write down a few things I noticed about the process before I forget. This was a bit tricky for me because the Vue site usually assumes that you’re using Node as part of your build process in some way (there’s a lot of “step 1: npm install THING ), and I didn’t want to use Node/Deno/etc. But it turned out to not be too complicated. The project I’m going to talk about testing is this zine feedback site I wrote in 2023 . the test framework: QUnit I used QUnit . It worked great but I don’t have anything interesting to say about how it works so I’ll leave it at that. I think that Alex’s “write your own test framework” approach would have worked too. I followed these directions . I did appreciate that QUnit has a “rerun test” button that will only rerun 1 test. Because there are so many network requests in my tests, having a way to run just 1 test makes it a lot less confusing to debug the test. step 1: set up the component for testing The first thing I needed to do was get my Vue components set up in the test environment. I changed my main app to put all my components in window._components , kind of like this: const components = { 'Feedback': FeedbackComponent, ... } window._components = components; Then I was able to write a mountComponent function which does basically exactly the same thing my normal main app does (render a tiny template with the component I want to use). The only differences are: I can optionally pass some some extra data to use as its props. It mounts the component to a temporary invisible div which will get removed from the DOM after the test is done. The div is positioned off the page ( position: absolute; top: -10000, ... ) so you can’t see it. Here’s what using the mountComponent function looks like: const {div} = mountComponent( '<Page :feedbacks="feedbacks" id=2 />', {feedbacks: [testFeedback]}, ); and here’s the code for it: function mountComponent(template, data) { const app = Vue.createApp({ template: template, data: () => data, }) for (const [c, v] of Object.entries(window._components)) { app.component(c, v); } const div = document.getElementById('qunit-fixture') .appendChild(document.createElement('div')); return div; } The result is a div where I can programmatically click, fill in form data, check that the right content appears, etc. step 2: add some fixture data Because I was writing end-to-end integration tests to make sure my client JS worked properly with my server, I needed to have some test data in my database. So I wrote ~25 lines of SQL to set up some test data in my database, and added an endpoint to my dev server to run the SQL to reset the test data to a known state. async function reset() { return fetch('/api/reset_test_data', {method: "POST"}) } Then I just run await reset() at the beginning of any test that needs the test data. My reset() function actually doesn’t always totally reset everything which is kind of bad, but it was workable to start with and can always be improved. step 3: a basic test Here’s what a basic test looks like! Basically we’re rendering the div and make sure it contains some approximately correct data. QUnit.test('renders feedback content', async function (assert) { const {div} = mountComponent( '<Page :feedbacks="feedbacks" id=2 image=2 page_hash=2 />', {feedbacks: [testFeedback]}, ); assert.ok(div.textContent.includes('loved this section')); }) Those are all the basic pieces! Now here are a few issues I ran into along the way waiting for parts of the page to render I have a lot of network requests in my tests, and it takes time for them to finish and for the Vue code to do what it has to do with the results and update the DOM. I think we all learned a long time ago that putting random sleep() calls in your tests and hoping that the timings are right is slow and flaky and extremely frustrating, so I needed a different way. As far as I can tell the normal way to deal with this is to figure out a way to tell from the DOM whether it’s okay to proceed or not. Like “if this button is visible, we can “. So I wrote a little waitFor() function that polls every 20ms to see if a condition has finished yet. It times out after 2 seconds. Here’s what using it looks like: QUnit.test("click item", async function (assert) { const {div} = mountComponent( '<Feedback zine_id="test123" image_width="800px" />', {}); const item = await waitFor(() => div.querySelector('.feedback-item')); item.click(); // rest of test goes here... }) It looks like there are a lot of implementations of this concept out there and they’re all better thought-through than mine. (from a quick Google: qunit-wait-for , playwright expect.poll ) figuring out the right thing to wait for is not straightforward In some cases I thought I’d identified the right thing to wait for in the DOM (“just wait for this textarea to appear!’) but it turned out that because of some internal details of how my program works, actually I needed to wait for something else later on which was hard to pin down. I ended up changing one of my components to add some random value to the DOM when it was finished an important action (like data-this-thing-is-ready=true ) which didn’t feel great. My best guess is that the right way to fix this kind of test issue is a refactor that also makes the app more reliable for the users: if there’s an element in the DOM that isn’t actually ready for the user to interact with, maybe I shouldn’t be displaying it yet! adding some CSS classes to identify things (but is that right?) I ended up adding a few classes to HTML elements that I needed to find in the tests, either because I needed to click on them or wait for them to appear in the DOM. I might want to change this approach later - frontend testing frameworks seem to suggest avoiding using CSS classes and instead using something like getByRole or as a last resort something like a data-testid . Feels like there’s a way to make the app more accessible and easier to test at the same time. filling out forms is tricky To fill out a form, I can’t just set the value , I also need to dispatch an event to tell Vue that the element has changed. For example, checkbox and textarea need different kinds of events. textarea.value = 'banana banana banana'; textarea.dispatchEvent(new Event('input')); checkbox.checked = true; checkbox.dispatchEvent(new Event('change')); This is kind of annoying and it made me realize why I might want to use some kind of UI testing library, for example: Testing Library’s example of filling out a form looks extremely different from what I’m doing Vue Test Utils: their section on form handling looks like it simplifies this a lot. test coverage I want to have an idea of what my test coverage was, and it turns out that Chrome actually has a built-in code coverage feature for JS and CSS! My JS is bundled into a file called bundle.js with esbuild, so I could just look at bundle.js and see which lines weren’t covered. The process was a little finicky: I had to turn off sourcemaps in the Chrome devtools to get this to work, and there’s a specific not super obvious series of actions I have to do in order to see the coverage data. this was so fun! As usual with these posts I’ve never really worked as a frontend or backend developer (other than for myself!) and I feel like I’m constantly learning how to do super basic tasks. I really had a blast doing this. My frontend projects always feel so fragile because they’re untested, and maybe one day I’ll have a test suite I’m confident in! Some things I’m still thinking about: While writing this post I found this frontend testing library called Testing Library that has a lot of guidelines for how to write tests that are very different from my initial ideas. I experimented with rewriting everything to use Testing Library and it felt pretty good, so we’ll see how that goes. They distribute a .umd.js file that works without Node. I’m not sure how I feel about not having a way to run these tests on the command line at all. Maybe there’s a simple way to work primarily in the browser but have an way to run them in CI too if I want?
- Examples for the tcpdump and dig man pagesMar 10, 2026
Hello! My big takeaway from last month’s musings about man pages was that examples in man pages are really great, so I worked on adding (or improving) examples to two of my favourite tools’ man pages. Here they are: the dig man page (now with examples) the tcpdump man page examples (this one is an update to the previous examples) the goal: include the most basic examples The goal here was really just to give the absolute most basic examples of how to use the tool, for people who use tcpdump or dig infrequently (or have never used it before!) and don’t remember how it works. So far saying “hey, I want to write an examples section for beginners and infrequent users of this tools” has been working really well. It’s easy to explain, I think it makes sense from everything I’ve heard from users about what they want from a man page, and maintainers seem to find it compelling. Thanks to Denis Ovsienko, Guy Harris, Ondřej Surý, and everyone else who reviewed the docs changes, it was a good experience and left me motivated to do a little more work on man pages. why improve the man pages? I’m interested in working on tools’ official documentation right now because: Man pages can actually have close to 100% accurate information! Going through a review process to make sure that the information is actually true has a lot of value. Even with basic questions “what are the most commonly used tcpdump flags”, often maintainers are aware of useful features that I’m not! For example I learned by working on these tcpdump examples that if you’re saving packets to a file with tcpdump -w out.pcap , it’s useful to pass -v to print a live summary of how many packets have been captured so far. That’s really useful, I didn’t know it, and I don’t think I ever would have noticed it on my own. It’s kind of a weird place for me to be because honestly I always kind of assume documentation is going to be hard to read, and I usually just skip it and read a blog post or Stack Overflow comment or ask a friend instead. But right now I’m feeling optimistic, like maybe the documentation doesn’t have to be bad? Maybe it could be just as good as reading a really great blog post, but with the benefit of also being actually correct? I’ve been using the Django documentation recently, and it’s really good! We’ll see. on avoiding writing the man page language The tcpdump project tool’s man page is written in the roff language , which is kind of hard to use and that I really did not feel like learning it. I handled this by writing a very basic markdown-to-roff script to convert Markdown to roff, using similar conventions to what the man page was already using. I could maybe have just used pandoc, but the output pandoc produced seemed pretty different, so I thought it might be better to write my own script instead. Who knows. I did think it was cool to be able to just use an existing Markdown library’s ability to parse the Markdown AST and then implement my own code-emitting methods to format things in a way that seemed to make sense in this context. man pages are complicated I went on a whole rabbit hole learning about the history of roff , how it’s evolved since the 70s, and who’s working on it today, inspired by learning about the mandoc project that BSD systems (and some Linux systems, and I think Mac OS) use for formatting man pages. I won’t say more about that today though, maybe another time. In general it seems like there’s a technical and cultural divide in how documentation works on BSD and on Linux that I still haven’t really understood, but I have been feeling curious about what’s going on in the BSD world. The comments section is here .
- Notes on clarifying man pagesFeb 18, 2026
Hello! After spending some time working on the Git man pages last year, I’ve been thinking a little more about what makes a good man page. I’ve spent a lot of time writing cheat sheets for tools (tcpdump, git, dig, etc) which have a man page as their primary documentation. This is because I often find the man pages hard to navigate to get the information I want. Lately I’ve wondering – could the man page itself have an amazing cheat sheet in it? What might make a man page easier to use? I’m still very early in thinking about this but I wanted to write down some quick notes. I asked some people on Mastodon for their favourite man pages, and here are some examples of interesting things I saw on those man pages. an OPTIONS SUMMARY If you’ve read a lot of man pages you’ve probably seen something like this in the SYNOPSIS : once you’re listing almost the entire alphabet, it’s hard ls [-@ABCFGHILOPRSTUWabcdefghiklmnopqrstuvwxy1%,] grep [-abcdDEFGHhIiJLlMmnOopqRSsUVvwXxZz] The rsync man page has a solution I’ve never seen before: it keeps its SYNOPSIS very terse, like this: Local: rsync [OPTION...] SRC... [DEST] and then has an “OPTIONS SUMMARY” section with a 1-line summary of each option, like this: --verbose, -v increase verbosity --info=FLAGS fine-grained informational verbosity --debug=FLAGS fine-grained debug verbosity --stderr=e|a|c change stderr output mode (default: errors) --quiet, -q suppress non-error messages --no-motd suppress daemon-mode MOTD Then later there’s the usual OPTIONS section with a full description of each option. an OPTIONS section organized by category The strace man page organizes its options by category (like “General”, “Startup”, “Tracing”, and “Filtering”, “Output Format”) instead of alphabetically. As an experiment I tried to take the grep man page and make an “OPTIONS SUMMARY” section grouped by category, you can see the results here . I’m not sure what I think of the results but it was a fun exercise. When I was writing that I was thinking about how I can never remember the name of the -l grep option. It always takes me what feels like forever to find it in the man page and I was trying to think of what structure would make it easier for me to find. Maybe categories? a cheat sheet A couple of people pointed me to the suite of Perl man pages ( perlfunc , perlre , etc), and one thing I noticed was man perlcheat , which has cheat sheet sections like this: SYNTAX foreach (LIST) { } for (a;b;c) { } while (e) { } until (e) { } if (e) { } elsif (e) { } else { } unless (e) { } elsif (e) { } else { } given (e) { when (e) {} default {} } I think this is so cool and it makes me wonder if there are other ways to write condensed ASCII 80-character-wide cheat sheets for use in man pages. examples are very popular A common comment was something to the effect of “I like any man page that has examples”. Someone mentioned the OpenBSD man pages, and the openbsd tail man page has examples of the exact 2 ways I use tail at the end. I think I’ve most often seen the EXAMPLES section at the end of the man page, but some man pages (like the rsync man page from earlier) start with the examples. When I was working on the git-add and git rebase man pages I put a short example at the beginning. a table of contents, and links between sections This isn’t a property of the man page itself, but one issue with man pages in the terminal is it’s hard to know what sections the man page has. When working on the Git man pages, one thing Marie and I did was to add a table of contents to the sidebar of the HTML versions of the man pages hosted on the Git site. I’d also like to add more hyperlinks to the HTML versions of the Git man pages at some point, so that you can click on “INCOMPATIBLE OPTIONS” to get to that section. It’s very easy to add links like this in the Git project since Git’s man pages are generated with AsciiDoc. I think adding a table of contents and adding internal hyperlinks is kind of a nice middle ground where we can make some improvements to the man page format (in the HTML version of the man page at least) without maintaining a totally different form of documentation. Though for this to work you do need to set up a toolchain like Git’s AsciiDoc system. It would be amazing if there were some kind of universal system to make it easy to look up a specific option in a man page (“what does -a do?”). The best trick I know is use the man pager to search for something like ^ *-a but I never remember to do it and instead just end up going through every instance of -a in the man page until I find what I’m looking for. examples for every option The curl man page has examples for every option, and there’s also a table of contents on the HTML version so you can more easily jump to the option you’re interested in. For instance the example for --cert makes it easy to see that you likely also want to pass the --key option, like this: curl --cert certfile --key keyfile https://example.com The way they implement this is that there’s [one file for each option]( https://github.com/curl/curl/blob/dc08922a61efe546b318daf964514ffbf41583 25/docs/cmdline-opts/append.md) and there’s an “Example” field in that file. formatting data in a table Quite a few people said that man ascii was their favourite man page, which looks like this: Oct Dec Hex Char ─────────────────────────────────────────── 000 0 00 NUL '\0' (null character) 001 1 01 SOH (start of heading) 002 2 02 STX (start of text) 003 3 03 ETX (end of text) 004 4 04 EOT (end of transmission) 005 5 05 ENQ (enquiry) 006 6 06 ACK (acknowledge) 007 7 07 BEL '\a' (bell) 010 8 08 BS '\b' (backspace) 011 9 09 HT '\t' (horizontal tab) 012 10 0A LF '\n' (new line) Obviously man ascii is an unusual man page but I think what’s cool about this man page (other than the fact that it’s always useful to have an ASCII reference) is it’s very easy to scan to find the information you need because of the table format. It makes me wonder if there are more opportunities to display information in a “table” in a man page to make it easier to scan. the GNU approach When I talk about man pages it often comes up that the GNU coreutils man pages (for example man tail ) don’t have examples, unlike the OpenBSD man pages, which do have examples . I’m not going to get into this too much because it seems like a fairly political topic and I definitely can’t do it justice here, but here are some things I believe to be true: The GNU project prefers to maintain documentation in “info” manuals instead of man pages. This page says “the man pages are no longer being maintained”. There are 3 ways to read “info” manuals: their HTML version, in Emacs, or with a standalone info tool. I’ve heard from some Emacs users that they like the Emacs info browser. I don’t think I’ve ever talked to anyone who uses the standalone info tool. The info manual entry for tail is linked at the bottom of the man page, and it does have examples The FSF used to sell print books of the GNU software manuals (and maybe they still do sometimes?) After a certain level of complexity a man page gets really hard to navigate: while I’ve never used the coreutils info manual and probably won’t, I would almost certainly prefer to use the GNU Bash reference manual or the The GNU C Library Reference Manual via their HTML documentation rather than through a man page. a few more man-page-adjacent things Here are some tools I think are interesting: The fish shell comes with a Python script to automatically generate tab completions from man pages tldr.sh is a community maintained database of examples, for example you can run it as tldr grep . Lots of people have told me they find it useful. the Dash Mac docs browser has a nice man page viewer in it. I still use the terminal man page viewer but I like that it includes a table of contents, it looks like this: it’s interesting to think about a constrained format Man pages are such a constrained format and it’s fun to think about what you can do with such limited formatting options. Even though I’m very into writing I’ve always had a bad habit of never reading documentation and so it’s a little bit hard for me to think about what I actually find useful in man pages, I’m not sure whether I think most of the things in this post would improve my experience or not. (Except for examples, I LOVE examples) So I’d be interested to hear about other man pages that you think are well designed and what you like about them, the comments section is here .
- Some notes on starting to use DjangoJan 27, 2026
Hello! One of my favourite things is starting to learn an Old Boring Technology that I’ve never tried before but that has been around for 20+ years. It feels really good when every problem I’m ever going to have has been solved already 1000 times and I can just get stuff done easily. I’ve thought it would be cool to learn a popular web framework like Rails or Django or Laravel for a long time, but I’d never really managed to make it happen. But I started learning Django to make a website a few months back, I’ve been liking it so far, and here are a few quick notes! less magic than Rails I spent some time trying to learn Rails in 2020, and while it was cool and I really wanted to like Rails (the Ruby community is great!), I found that if I left my Rails project alone for months, when I came back to it it was hard for me to remember how to get anything done because (for example) if it says resources :topics in your routes.rb , on its own that doesn’t tell you where the topics routes are configured, you need to remember or look up the convention. Being able to abandon a project for months or years and then come back to it is really important to me (that’s how all my projects work!), and Django feels easier to me because things are more explicit. In my small Django project it feels like I just have 5 main files (other than the settings files): urls.py , models.py , views.py , admin.py , and tests.py , and if I want to know where something else is (like an HTML template) is then it’s usually explicitly referenced from one of those files. a built-in admin For this project I wanted to have an admin interface to manually edit or view some of the data in the database. Django has a really nice built-in admin interface, and I can customize it with just a little bit of code. For example, here’s part of one of my admin classes, which sets up which fields to display in the “list” view, which field to search on, and how to order them by default. @admin.register(Zine) class ZineAdmin(admin.ModelAdmin): list_display = ["name", "publication_date", "free", "slug", "image_preview"] search_fields = ["name", "slug"] readonly_fields = ["image_preview"] ordering = ["-publication_date"] it’s fun to have an ORM In the past my attitude has been “ORMs? Who needs them? I can just write my own SQL queries!”. I’ve been enjoying Django’s ORM so far though, and I think it’s cool how Django uses __ to represent a JOIN , like this: Zine.objects .exclude(product__order__email_hash=email_hash) This query involves 5 tables: zines , zine_products , products , order_products , and orders . To make this work I just had to tell Django that there’s a ManyToManyField relating “orders” and “products”, and another ManyToManyField relating “zines”, and “products”, so that it knows how to connect zines , orders , products . I definitely could write that query, but writing product__order__email_hash is a lot less typing, it feels a lot easier to read, and honestly I think it would take me a little while to figure out how to construct the query (which needs to do a few other things than just those joins). I have zero concern about the performance of my ORM-generated queries so I’m pretty excited about ORMs for now, though I’m sure I’ll find things to be frustrated with eventually. automatic migrations! The other great thing about the ORM is migrations! If I add, delete, or change a field in models.py , Django will automatically generate a migration script like migrations/0006_delete_imageblob.py . I assume that I could edit those scripts if I wanted, but so far I’ve just been running the generated scripts with no change and it’s been going great. It really feels like magic. I’m realizing that being able to do migrations easily is important for me right now because I’m changing my data model fairly often as I figure out how I want it to work. I like the docs I had a bad habit of never reading the documentation but I’ve been really enjoying the parts of Django’s docs that I’ve read so far. This isn’t by accident: Jacob Kaplan-Moss has a talk from PyCon 2011 on Django’s documentation culture. For example the intro to models lists the most important common fields you might want to set when using the ORM. using sqlite After having a bad experience trying to operate Postgres and not being able to understand what was going on, I decided to run all of my small websites with SQLite instead. It’s been going way better, and I love being able to backup by just doing a VACUUM INTO and then copying the resulting single file. I’ve been following these instructions for using SQLite with Django in production. I think it should be fine because I’m expecting the site to have a few hundred writes per day at most, much less than Mess with DNS which has a lot more of writes and has been working well (though the writes are split across 3 different SQLite databases). built in email (and more) Django seems to be very “batteries-included”, which I love – if I want CSRF protection, or a Content-Security-Policy , or I want to send email, it’s all in there! For example, I wanted to save the emails Django sends to a file in dev mode (so that it didn’t send real email to real people), which was just a little bit of configuration. I just put this settings/dev.py : EMAIL_BACKEND = "django.core.mail.backends.filebased.EmailBackend" EMAIL_FILE_PATH = BASE_DIR / "emails" and then set up the production email like this in settings/production.py EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" EMAIL_HOST = "smtp.whatever.com" EMAIL_PORT = 587 EMAIL_USE_TLS = True EMAIL_HOST_USER = "xxxx" EMAIL_HOST_PASSWORD = os.getenv('EMAIL_API_KEY') That made me feel like if I want some other basic website feature, there’s likely to be an easy way to do it built into Django already. the settings file still feels like a lot I’m still a bit intimidated by the settings.py file: Django’s settings system works by setting a bunch of global variables in a file, and I feel a bit stressed about… what if I make a typo in the name of one of those variables? How will I know? What if I type WSGI_APPLICATOIN = "config.wsgi.application" instead of WSGI_APPLICATION ? I guess I’ve gotten used to having a Python language server tell me when I’ve made a typo and so now it feels a bit disorienting when I can’t rely on the language server support. that’s all for now! I haven’t really successfully used an actual web framework for a project before (right now almost all of my websites are either a single Go binary or static sites), so I’m interested in seeing how it goes! There’s still lots for me to learn about, I still haven’t really gotten into Django’s form validation tooling or authentication systems. Thanks to Marco Rogers for convincing me to give ORMs a chance. (we’re still experimenting with the comments-on-Mastodon system! Here are the comments on Mastodon ! tell me your favourite Django feature!)
- A data model for Git (and other docs updates)Jan 08, 2026
Hello! This past fall, I decided to take some time to work on Git’s documentation. I’ve been thinking about working on open source docs for a long time – usually if I think the documentation for something could be improved, I’ll write a blog post or a zine or something. But this time I wondered: could I instead make a few improvements to the official documentation? So Marie and I made a few changes to the Git documentation! a data model for Git After a while working on the documentation, we noticed that Git uses the terms “object”, “reference”, or “index” in its documentation a lot, but that it didn’t have a great explanation of what those terms mean or how they relate to other core concepts like “commit” and “branch”. So we wrote a new “data model” document! You can read the data model here for now . I assume at some point (after the next release?) it’ll also be on the Git website . I’m excited about this because understanding how Git organizes its commit and branch data has really helped me reason about how Git works over the years, and I think it’s important to have a short (1600 words!) version of the data model that’s accurate. The “accurate” part turned out to not be that easy: I knew the basics of how Git’s data model worked, but during the review process I learned some new details and had to make quite a few changes (for example how merge conflicts are stored in the staging area). updates to git push , git pull , and more I also worked on updating the introduction to some of Git’s core man pages. I quickly realized that “just try to improve it according to my best judgement” was not going to work: why should the maintainers believe me that my version is better? I’ve seen a problem a lot when discussing open source documentation changes where 2 expert users of the software argue about whether an explanation is clear or not (“I think X would be a good way to explain it! Well, I think Y would be better!”) I don’t think this is very productive (expert users of a piece of software are notoriously bad at being able to tell if an explanation will be clear to non-experts), so I needed to find a way to identify problems with the man pages that was a little more evidence-based. getting test readers to identify problems I asked for test readers on Mastodon to read the current version of documentation and tell me what they find confusing or what questions they have. About 80 test readers left comments, and I learned so much! People left a huge amount of great feedback, for example: terminology they didn’t understand (what’s a pathspec? what does “reference” mean? does “upstream” have a specific meaning in Git?) specific confusing sentences suggestions of things things to add (“I do X all the time, I think it should be included here”) inconsistencies (“here it implies X is the default, but elsewhere it implies Y is the default”) Most of the test readers had been using Git for at least 5-10 years, which I think worked well – if a group of test readers who have been using Git regularly for 5+ years find a sentence or term impossible to understand, it makes it easy to argue that the documentation should be updated to make it clearer. I thought this “get users of the software to comment on the existing documentation and then fix the problems they find” pattern worked really well and I’m excited about potentially trying it again in the future. the man page changes We ended updating these 4 man pages: git add ( before , after ) git checkout ( before , after ) git push ( before , after ) git pull ( before , after ) The git push and git pull changes were the most interesting to me: in addition to updating the intro to those pages, we also ended up writing: a section describing what the term “upstream branch” means (which previously wasn’t really explained) a cleaned-up description of what a “push refspec” is Making those changes really gave me an appreciation for how much work it is to maintain open source documentation: it’s not easy to write things that are both clear and true, and sometimes we had to make compromises, for example the sentence “ git push may fail if you haven’t set an upstream for the current branch, depending on what push.default is set to.” is a little vague, but the exact details of what “depending” means are really complicated and untangling that is a big project. on the process for contributing to Git It took me a while to understand Git’s development process. I’m not going to try to describe it here (that could be a whole other post!), but a few quick notes: Git has a Discord server with a “my first contribution” channel for help with getting started contributing. I found people to be very welcoming on the Discord. I used GitGitGadget to make all of my contributions. This meant that I could make a GitHub pull request (a workflow I’m comfortable with) and GitGitGadget would convert my PRs into the system the Git developers use (emails with patches attached). GitGitGadget worked great and I was very grateful to not have to learn how to send patches by email with Git. Otherwise I used my normal email client (Fastmail’s web interface) to reply to emails, wrapping my text to 80 character lines since that’s the mailing list norm. I also found the mailing list archives on lore.kernel.org hard to navigate, so I hacked together my own git list viewer to make it easier to read the long mailing list threads. Many people helped me navigate the contribution process and review the changes: thanks to Emily Shaffer, Johannes Schindelin (the author of GitGitGadget), Patrick Steinhardt, Ben Knoble, Junio Hamano, and more. (I’m experimenting with comments on Mastodon, you can see the comments here )
- Notes on switching to Helix from vimOct 10, 2025
Hello! Earlier this summer I was talking to a friend about how much I love using fish , and how I love that I don’t have to configure it. They said that they feel the same way about the helix text editor, and so I decided to give it a try. I’ve been using it for 3 months now and here are a few notes. why helix: language servers I think what motivated me to try Helix is that I’ve been trying to get a working language server setup (so I can do things like “go to definition”) and getting a setup that feels good in Vim or Neovim just felt like too much work. After using Vim/Neovim for 20 years, I’ve tried both “build my own custom configuration from scratch” and “use someone else’s pre-buld configuration system” and even though I love Vim I was excited about having things just work without having to work on my configuration at all. Helix comes with built in language server support, and it feels nice to be able to do things like “rename this symbol” in any language. the search is great One of my favourite things about Helix is the search! If I’m searching all the files in my repository for a string, it lets me scroll through the potential matching files and see the full context of the match, like this: For comparison, here’s what the vim ripgrep plugin I’ve been using looks like: There’s no context for what else is around that line. the quick reference is nice One thing I like about Helix is that when I press g , I get a little help popup telling me places I can go. I really appreciate this because I don’t often use the “go to definition” or “go to reference” feature and I often forget the keyboard shortcut. some vim -> helix translations Helix doesn’t have marks like ma , 'a , instead I’ve been using Ctrl+O and Ctrl+I to go back (or forward) to the last cursor location I think Helix does have macros, but I’ve been using multiple cursors in every case that I would have previously used a macro. I like multiple cursors a lot more than writing macros all the time. If I want to batch change something in the document, my workflow is to press % (to highlight everything), then s to select (with a regex) the things I want to change, then I can just edit all of them as needed. Helix doesn’t have neovim-style tabs, instead it has a nice buffer switcher ( <space>b ) I can use to switch to the buffer I want. There’s a pull request here to implement neovim-style tabs. There’s also a setting bufferline="multiple" which can act a bit like tabs with gp , gn for prev/next “tab” and :bc to close a “tab”. some helix annoyances Here’s everything that’s annoyed me about Helix so far. I like the way Helix’s :reflow works much less than how vim reflows text with gq . It doesn’t work as well with lists. ( github issue ) If I’m making a Markdown list, pressing “enter” at the end of a list item won’t continue the list. There’s a partial workaround for bulleted lists but I don’t know one for numbered lists. No persistent undo yet: in vim I could use an undofile so that I could undo changes even after quitting. Helix doesn’t have that feature yet. ( github PR ) Helix doesn’t autoreload files after they change on disk, I have to run :reload-all ( :ra<tab> ) to manually reload them. Not a big deal. Sometimes it panics, maybe every week or so. I think it might be this issue . The crashes look something like this: thread 'main' panicked at helix-core/src/transaction.rs:499:9: Positions [(2959, AfterSticky), (2959, AfterSticky)] are out of range for changeset len 2945! note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace The “markdown list” and reflowing issues come up a lot for me because I spend a lot of time editing Markdown lists, but I keep using Helix anyway so I guess they can’t be making me that mad. switching was easier than I thought I was worried that relearning 20 years of Vim muscle memory would be really hard. It turned out to be easier than I expected, I started using Helix on a vacation for a little low-stakes coding project I was doing on the side and after a week or two it didn’t feel so disorienting anymore. I think it might be hard to switch back and forth between Vim and Helix, but I haven’t needed to use Vim recently so I don’t know if that’ll ever become an issue for me. The first time I tried Helix I tried to force it to use keybindings that were more similar to Vim and that did not work for me. Just learning the “Helix way” was a lot easier. There are still some things that throw me off: for example w in vim and w in Helix don’t have the same idea of what a “word” is (the Helix one includes the space after the word, the Vim one doesn’t). using a terminal-based text editor For many years I’d mostly been using a GUI version of vim/neovim, so switching to actually using an editor in the terminal was a bit of an adjustment. I ended up deciding on: Every project gets its own terminal window, and all of the tabs in that window (mostly) have the same working directory I make my Helix tab the first tab in the terminal window It works pretty well, I might actually like it better than my previous workflow. my configuration I appreciate that my configuration is really simple, compared to my neovim configuration which is hundreds of lines. It’s mostly just 4 keyboard shortcuts. theme = "solarized_light" [editor] # Sync clipboard with system clipboard default-yank-register = "+" [keys.normal] # I didn't like that Ctrl+C was the default "toggle comments" shortcut "#" = "toggle_comments" # I didn't feel like learning a different way # to go to the beginning/end of a line so # I remapped ^ and $ "^" = "goto_first_nonwhitespace" "$" = "goto_line_end" [keys.select] "^" = "goto_first_nonwhitespace" "$" = "goto_line_end" [keys.normal.space] # I write a lot of text so I need to constantly reflow, # and missed vim's `gq` shortcut l = ":reflow" There’s a separate languages.toml configuration where I set some language preferences, like turning off autoformatting. For example, here’s my Python configuration: [[language]] name = "python" formatter = { command = "black", args = ["--stdin-filename", "%{buffer_name}", "-"] } language-servers = ["pyright"] auto-format = false we’ll see how it goes Three months is not that long, and it’s possible that I’ll decide to go back to Vim at some point. For example, I wrote a post about switching to nix a while back but after maybe 8 months I switched back to Homebrew (though I’m still using NixOS to manage one little server, and I’m still satisfied with that).
Antirez
- Not just development, distribution of software may change as wellJul 22, 2026
Even if you are as averse to semver as I used to be in the course of my programming activity, you can still think of open source software distribution as something that used to follow a fixed number of steps. There is a branch where developments happen, and this branch oftentimes happens to be not really ready for reliable work. Then you freeze the developments for a certain amount of time (even if, in the meantime, the work can continue on some new unstable branch), fix bugs, ask people to test it. At some point the number of bug reports starts to drop, your team and your users start to believe there are no longer obvious critical flaws that are easy to discover in the next few weeks: then you call the branch 2.4 or whatever, and that's it. However now, with AI coding, it's not just development that has changed, but also the act itself of using software is affected: it is not just you that can ask an AI to do certain changes to the software, but also the recipient of the software itself. This is obvious in the domains where a piece of software has its main user base among programmers, but this is also true in general, as more and more technologically inclined users have AI access and coding agents. Because of this change, the idea of just having a stable branch with everything polished, and an unstable branch where everything is a work in progress, may no longer be the right way to do things. A code repository can also be a finished product, but could be even more useful if it is a template for how to do things around a given problem. Maybe the user will modify the code in order to specialize it for a specific set of requirements, hardware, specific problems to solve. Also, what is too unstable or unproven for the general public may be the right thing for another set of users. Take the example of Redis. For weeks now I have been iterating on a PR that provides strong memory savings for sorted sets. This work, if accepted, will hit every user of Redis, from people that don't have any idea about how Redis works, to users that maybe even contributed code in the course of years. From use cases that are trivial to use cases where a 50% memory saving on sorted sets could mean cutting a big slice of the cloud bill every year. For this last kind of user, having the final product (after all the testing and changes of design I'm doing to refine something that "just works", with the risk that maybe it will not even enter the code base) may be less interesting than having a 95%-ready branch since day zero. It is code they can test, adapt, iterate on, even specialize more for the problem at hand. Maybe DwarfStar is an even more telling example of how code repositories should be good examples more than finished products covering every piece of the features matrix. With local inference you have, in the specific case of DwarfStar, many kinds of GPUs, models, server mode, agent mode, CLI, SSD streaming, tensor and pipeline distributed execution. To test everything everywhere is complicated. Yet, once you have two solid examples of tensor parallel graph execution, a strong coding agent can infer how to implement the same thing for other backend/model pairs. Similarly, once you have an engine that supports two models well enough, a third can be implemented in an almost automatic way, using the existing code base as a guardrail for coding agents in order to guide the implementation. This does not mean that a project like DwarfStar should not work out of the box, but that it could focus on supporting very well a set of features that can be extrapolated to a larger amount of possible situations that the users can cover themselves. It also means another thing: that main and unstable are no longer enough. Many experimental branches could be an integral part of the project. For instance, yesterday the Laguna S.1 model was released. It looks interesting on paper, however: will it really be good enough? Will the new DeepSeek v4 Flash checkpoints make it not really relevant for DwarfStar? It is too early to say. However, to collectively form an idea, publishing a branch with this model implementation is a good middle ground: people will try it, will refine it with their coding agents, and the community can collectively form an idea about how merge-worthy it is. Moreover, today I noticed how, thanks to the rails formed by the corpus of the code inside DwarfStar, the implementation was written in about two hours by GPT 5.6 Sol automatically. Implementing DS4 and GLM5.2 cost me a lot of steering, reading the model card and the details of the implementation of the attention of those models. Now it just worked. GPT 5.6 is more powerful but it also found a lot of good examples inside the existing source code. Software today is more malleable than ever. In some way this means that it can be released in a more fluid way. Also, it means that the documentation itself should not be just good for humans, but also for coding agents to understand how to change the system. How this will evolve exactly, and what the right point of balance between the different dimensions of stability, usability, and features will be, is not clear to me, but I believe we developers need to keep our eyes open to see where all this is headed. Comments
- Control the ideas, not the codeJul 13, 2026
Look at the past history of this blog. There are many blog posts about programming with AI, a few of them date back to January 2024 (like this: https://antirez.com/news/140). I’m a relatively well regarded programmer, after all. I don’t have the need to still be in the “loop” as a old man that seeks for relevance, I recently rejoined Redis, and now I also am developing a new open source software for local LLM inference that received a good welcome in the community. Why I keep doing this, of saying what people don’t want to hear? Why I keep announcing how future programming will be by default? Because I feel the urge of lowering the impact for people less prepared to the change than me, often younger than me, and that, unlikely me, didn’t see many of those things coming (In 2022 I published, before ChatGPT existed, a book preannouncing many things that now happened and other things that I believe *will* happen, so I feel like I can say this without sounding egocentric). So mine is a trick. People feel more and more programming is completely modified by AI and don’t know what they should do, if they can really start coding in a completely different way, without looking much at the code as their main output. They feel like they are betraying their own field. So my intention is to arrive and say “look at me, In can write code, you know, I’m not hiding behind AI: yet, things changed, it’s not your weakness, it’s not that you are AI-pilled. It is just that our field is evolving in an incredible *and* painful (but also joyful) direction”. This is why yesterday, on X, I said that I believe many programmers at this point have less impact they could have because they look at the code. I truly believe into that. And note that this does not mean to vibe code something just asking for the final product. The point is: if you control the ideas of your software, looking at the code itself is suboptimal and often pointless. For the following reasons: 1. You can now generate a lot of code, even *not* accounting for the LLM code verbosity (that is also effect of not being able to instruct them well, for most of the part). How are you supposed to review 5k lines of code every day? 2. LLMs are very good at writing locally optimal code, and are worse (but improving) with big ideas. What’s the point of scanning function by function, line by line? Instead you should prompt the design you have in mind, sometimes ask “how is exactly the design of that part? How does it work?”, and evaluate if it is the right model. It is much faster. 3. The working day is 8 hours. If you read the code, it is a tradeoff. You are doing less of what today is the most important part of your job, that is, asking yourself: what I’m doing with this software? What are the new directions I want to take? And also, think at new ideas, features, optimizations tricks. And doing a lot of QA. Controlling the ideas. Do you remember this phrasing from the Mythical Man Month? Well, a book from the 70s tells us more things about the current software era than many of the things that were said from 2000 to 2020. Why people that now protest against AI were not horrified by the state of software in the last decade? The level of slop we touched during recent years, before AI, is unbelievable. I’ll say you another thing. What is slop? With DwarfStar I implemented an inference for two LLMs (DeepSeek v4 and GLM 5.2) in a completely automated way, but: try it yourself, you will discover you can’t just say “implement XYZ” and see it working. You have to understand how things work, what is the best design, how to reach a certain level of performance. Then I compared the implementation, for correctness, to other systems, finding that other implementations sometimes contained more errors. I researched more, and found that the local inference world is full of subtle errors that accumulate and damage the model output, issues in the attention implementation causing performance slopes after the context is over a certain limit because indexed attention implementations are broken (do more work than they should, for instance), and so forth. This is the result of a domain that is very complicated to handle, fast changing, with models that are slightly different one from the other in the inference graph being released every day. It’s an unfair game for developers. Well: AI helps a lot with that. There are many domains where rigorous engineering (in the design side) and testing is *far* better than writing a GPU kernel by hand (or reading it). So are we sure most of that resistance it is not ideological? Matteo Collina yesterday asked me, in reply to my tweet: but didn’t you say that you check all the AI generated code for Redis? And this is a good question indeed. Yes, I do, but this is, at this point, something I *need* to do but that I believe to be mostly pointless, partially once GPT 5.5 was released, but now with Fable and GPT 5.6 Sol even more. Yes: I identify things that I don’t like how they are coded, but if I open other Redis files written by other Redis contributors there is *far worse*, and not since they are not good coders, but because it is a matter of taste. I write very clean code since I want it to be readable, so during the implementation of Redis Arrays I operated changes. I’m doing it again for the 50% memory saving optimization of Redis sorted sets, a PR that I’ll submit soon. But I do not feel this is useful anymore. Nobody should anymore look at this code, but only at the ideas the code contains. I continued to do it out of respect for users. Redis is at this point a commonly useful thing, and many programmers will open files and modify stuff by hand. But if I had my hands free, you know what I would do, instead? Use all the time that the review is taking me to do more QA, to think at the next optimization idea and apply it, and to use LLMs to write a DESIGN.md file where each data structure is described in human language, with the ideas it contains, the implementation tricks, the design. That, in the future, is going to be much more useful. Do you want to modify sorted sets? You open the file, read the design, then you own the ideas. You can open your agent and ask it what to do with the right mental model. This is a lot more useful than reviewing the code. Fable and GPT 5.6 reviews to the sorted sets memory saving are going to spot ways more errors and subtle race conditions that my review is going to uncover. Yet I’ll do it. But for the majority of software projects, all this does not make sense anymore. Focus on controlling the ideas, instead. Focus on quality, testing, and having an idea of the software you want to ship. The world changed and it is painful, but also full of opportunities to improve a software world that was already completely rotten. I have a doubt only regarding young programmers that don't have enough experience, and can't build a mental model. We don't know, yet, if they will require or not to understand very well how a given piece of code works, but I believe they should learn how to write programs. Yet, I'm not sure checking the LLM output is the right thing they should do. It may be a lot more useful if they learn some programming language and implement a small interpreter, a small database, an hash table and so forth. Reviewing some Javascript stuff of some web site for a customer? Hell, no, don't lose time with that shit. Comments
- A new era for software testingJun 07, 2026
Automatic programming dramatically speeds up writing software in certain use cases and in the right hands. In my experience the output does not reach the structural quality and economy of complexity of the best hand-written software. However, not all the software is stellar, and my feeling is that automatic programming surpasses most of the times (and if well managed) the quality of decently developed hand-written code. Yet, there is a tradeoff between quality and time, in the case of writing new software with AI. This tradeoff in certain projects I developed can be brutal, that is, completing projects that may take many months in a few weeks. However, there are domains where LLMs simply open new strictly more powerful ways to automate processes, without any compromise on quality. One of those domains is software QA and testing. Traditionally software is tested using test suites that are composed of locally-scoped tests and integration tests (think of Redis: one thing is testing if SET foo 10 will be matched by GET foo => 10, another thing is testing if replication works in this case). And then by QA passes that are usually manually executed, and that can capture holes in the runnable test suite. It is a known fact that covering all the lines of the code does not mean covering all the possible states. Moreover integration testing is structurally hard: there are a number of timing issues, setups, and certain quality outputs that can only be visually inspected and not automatically checked that leave a lot of testing opportunities not really exploited because of time or logistic constraints. LLMs offer a new way to do QA on top of the existing testing methodologies. The idea is to create a markdown file where an AI agent is asked to work as a QA engineer, performing a number of manual testings on the new release. For instance, in the case of DwarfStar (an inference engine for open weights LLMs) I use the following approach. In the markdown file, the agent is asked to check what are the new commits on top of the already released version of the software project. Then the model is told a list of things that should be performed, like: - Check that distributed inference works across MacBook A and MacBook B, making sure the output is coherent, the inference works with all the GGUF files we have in both the machines, ... - Make sure this release does not contain any speed regression. And so forth. Notably, in the speed regression part, I don't have to tell the agent what was the previous expected speed, as this is a moving target that changes with new releases and new optimizations. Similarly the integration test for distributed inference does not require many instructions, at the start of the file there are just SSH endpoints and the key to use, the paths, and so forth. The agent is asked to check the long list of QA activities *especially* in light of the added commits, starting with an inspection of the changes and with the identification of what could be affected, so that the QA pass specializes trying to find specific regressions. In the case of Redis Arrays, I used a similar methodology asking the agent to build a large array-based Redis application, to setup a production environment with replication and persistency, to simulate the usage of the application for days and with many users, checking if something was odd. Testing that uses these approaches may also move in the more psychological side of software quality, asking the agent to identify all the new features that may look surprising, not documented enough, or generally sloppy from the POV of the user. All things that needed to be executed manually before, and that most of the times were mostly skipped. I have the feeling that the introduction of automatic QA may raise the bar of quality for new releases of software, and maybe partially compensate for the lower quality of the code produced at high speed with the use of automatic programming. Comments
- Distributing LLM inference in DwarfStarMay 25, 2026
High end NVIDIA cards, and the server and power needed to run them, cost a lot of money, especially if you plan to reach enough VRAM to run massive models. The alternative, so far, has been Apple hardware, or the DGX Spark that, even if severely limited because of memory bandwidth, still allows to run LLMs prompt processing (prefill) fast enough. The Mac Studio provided up to 512GB unified memory, a solution with modest memory bandwidth (but much better than the Spark) and compute at a price that was, after all, given the current situation, relatively fair. For instance, with DwarfStar the Mac Studio M3 Ultra 512GB can run DeepSeek v4 PRO at 150 t/s prefill and ~10-13 t/s decoding, not great but at a level that is usable for certain use cases. Even 2-bit quantized, DeepSeek v4 PRO resists very well, like Flash at the same quantization (today I made PRO write a C compiler, I'll publish the video soon). I would not consider a trivial fact to run a frontier model at home, with a ~12k total spending. One could expect this to get better and better, but the situation at the horizon appears cloudy. There is almost zero hope that NVIDIA setups will get less expensive, and even a small company can’t afford to easily purchase and handle a small data center for local inference. At the same time the RAM shortage is making it not exactly likely that we will see a Mac Studio with an M5 Ultra, maybe 1.2T/s memory bandwidth and more compute (the M5 Max is already faster, compute wise, and has the Neural Accelerators inside each GPU core that help with certain models). So the current situation for local inference is that the best machine is probably a laptop. The M5 Max 128GB can run DeepSeek v4 Flash and Mimo V2.5, 2-bit quantized, at very decent prefill and decoding speeds. We are talking of ~500 t/s prefill and ~35-40t/s decoding speed, with a performance slope as the context size increases which is very acceptable. At the cost of 6-7k depending on the configuration, this is currently one of the best deals. If this is the situation, for local inference projects in general, and for DwarfStart in particular, looking at distributed inference starts to be interesting. What we can do if we have two, three, four MacBook M5 Max systems? Or two M3 Ultra with 512 GB of RAM? Traditionally there are two main systems to run distributed inference. One is to duplicate memory by loading 50% of the transformer layers in computer A, the remaining 50% on computer B, and running the inference in a sequential way. In this case there is to send just the activations around, that’s very simple conceptually, and with some micro-batching magic it is possible to not just duplicate the memory but even in theory to increase substantially the prompt processing speed (but not the decoding: for a single token generation you have to wait the first layers on machine A, the remaining layers on machine B, and so forth — but at least less heat will be produced so it is possible to use a sustained load), which is not bad at all. This means, for example, that the lucky ones that have two Mac Studio 512GB machines could run full size DeepSeek v4 PRO (even if even the 2-bit quants are running very, very well) and with micro-batching even enjoy a faster prefill. Another approach is, using Apple RDMA, to parallelize the execution across the two machines, a vertical split basically. For instance one could try to load the same 2 bit quants on machine A and B, so that both fit, and each side has *all* the routed experts. Then for each layer we could try to do the coordination needed in order to execute half the experts in machine A, half in machine B, and so forth (note that both machines have all the experts, so whatever the router says, we can send 50% of the computation to the other machine, and the activations are tiny). This is more viable for the PRO that has much larger routed experts, so the communication penalty is less sensible. But if this could be made to work well, is all to be seen. There is also tensor parallelism, you are thinking, right? But I bet this is not viable at all with the communication speed we have among two Apple computers, two DGX Spark and so forth (go read the speed of NVLink). The magic about the above two models is that you have to send very little data. Ok, so far I bet you are thinking, this is the same shit everybody knows about running LLMs in a parallel fashion, and indeed this is true. But this post was conceived to reach this exact point. What about if we could, instead, parallelize two Mac or DGX in a completely different way? Open weights models are now in a golden age, we have plenty and many are very powerful. In the 128GB 2-bit quants classes there are many interesting: Minimax M2.7, Mimo V2.5, DeepSeek v4 Flash, and a few more. At the same time it was recently noted that LLMs ensemble (https://arxiv.org/abs/2502.18036) is an understudied possibility that allows two models to run in a completely shared-nothing way in two different machines, to only combine the logits or select the best continuation at the end. There are different ways to do that, and it works even if the two models have different vocabularies: you can pick the continuation where the perplexity is lower (that is, pick the model which is more sure: it’s like a two experts MoE where the routing is implicit), and it is even possible to combine the logits (with some complexities given by the different vocabularies) and sample from there. More recent papers suggest that mixing the two techniques is the best approach. Anyway: these techniques seem to really work, models appear to do better than alone. It’s like if their knowledge is improved because each one brings his POV on what to say next. Maybe this is one of the most logical third approach to try, other than the first two. I really hope to find the time to play more with all that, in the next months. Comments
- Alternatives for the EDIT tool of LLM agentsMay 19, 2026
EDIT: of course this was already done in the past! I had little doubts but people just confirmed me about it on Twitter :) But, keep reading: the CRC32 compromise at the end is an interesting tradeoff, and this is a good discussion to have in general. Right now I'm working to an agent for my DS4 project. Local inference is token-poor, it's a battlefield where optimizations count. I was quite surprised by the fact the EDIT tool everybody is using right now forces the LLM to emit the old version of the text verbatim. This CAS (check and set) mode of operation, where I say EDIT old="foo" new="bar", is needed because there are often colliding edits (the user is editing as well, or checked out a different branch, and so forth) and because the LLM can just hallucinate that a given line had a given content. This means, basically, that just using line numbers is very fragile: to say, change line 22 with new="foobar" is not good. Yet I don't want my local LLM to throw away tokens rewriting the old text each time, also because certain times the old text has a lot of special chars and spaces that the model may get wrong; in this case the tool would fail, forcing the LLM to do the same edit again. So I (re)designed a tag-based EDIT tool that is still CAS style, but more tokens efficient. The READ and SEARCH tools return something like that: 10:Q8fA int count = 10; 11:rA3_ if (count > limit) { 12:Kq9z count = limit; 13:PX0b } So there are line numbers and tags. The tag is 4 chars, on average 2.5 LLM tokens, representing a checksum of the line. Now the LLM can edit like this: { "tool": "edit", "path": "/tmp/example.c", "line": 10, "tag": "Q8fA", "new": "int count = 11;" } Or, multi line, like this: { "tool": "edit", "path": "/tmp/example.c", "lines": "11:rA3_\n12:Kq9z\n13:PX0b", "new": "if (count > limit)\n return limit;" } The saving is significant especially when the agent is deleting big amounts of text, but also in the general case. However, there is some overhead due to the fact we have line numbers and tags. There are potential tradeoffs, maybe the tag should be 8 chars and include the line number in the hash, there is to check exactly collisions possibilities and tokenization to see how much this is a win, but I like the line:tag format as later the LLM is often able to exploit the line information in many ways, like to get ranges in successive tool calls. Maybe there are other ways to exploit the tag, too, like: is this line still dj4_? The interesting thing is that DeepSeek v4 Flash is able to use this tool in a very effective way, so apparently it is natural for it. And while I did't measure the exact savings I saw in the field that edits are much faster and even more reliable. The alternative to this is to return just the whole file CRC32 each time (basically the tag becomes a file tag, even for partial reads). So that we can only work with line numbers + CRC, the edit would just specify 11,12,13,14. Less tokens, of course. This forces, however, to recompute the CRC32 of the file each time, but for reasonably sized files this is cheap enough. But this approach has limits: we fail the edit even if *unrelated* changes happened, so there is a strong tradeoff at play. To be fair to the whole file method, there is to say that it allows to specify ranges as 10:23, which is a huge win. I have the feeling I can decide which is better only with enough practical evidence, by using ds4-agent over multiple sessions with the two systems. For now maybe a command line to switch the edit mode is the first right step to do. Comments
- A few words on DS4May 15, 2026
I didn’t expect DwarfStar 4 (https://github.com/antirez/ds4) to become so popular so fast. It is clear that there was a need for single-model integration focused local AI experience, and that a few things happened together: the release of a quasi-frontier model that is large and fast enough to change the game of local inference, and the fact that it works extremely well with an extremely asymmetric quants recipe of 2/8 bit, so that 96 or 128GB of RAM are enough to run it. And, of course: all the experience produced by the local AI movement in the latest years, that can be leveraged more promptly because of GPT 5.5 (otherwise you can’t build DS4 in one week — and even with all this help you need to know how to gently talk to LLMs). The last week was funny and also tiring, I worked 14 hours per day on average. My normal average is 4/6 since early Redis times, but the first few months of Redis were like that. So, what’s next? Is this a project that starts and ends with DeepSeek v4 Flash? Nope, the model can change over time. The space will be occupied, in my vision, by the best current open weights model that is *practically fast* on a high end Mac or “GPU in a box” gear (like the DGX Spark and other similar setups). I bet that the next contender is DeepSeek v4 Flash itself, in the new checkpoint that will be released and, hopefully, a version specifically tuned for coding, and who knows, other expert-variants (not in the sense of MoE experts) maybe. For local inference, to have a ds4-coding, ds4-legal, ds4-medical models make a lot of sense, after all. You just load what you need depending on the question. It is the first time since I play with local inference (I play with it since the start) that I find myself using a local model for serious stuff that I would normally ask to Claude / GPT. This, I think, is really a big thing. It is also the first time that using vector steering I can enjoy an experience where the LLM can be used with more freedom. DeepSeek v4 Flash is really an impressive model, no doubt about that. If you can imagine in your mind the small good local model experience as A, and the frontier model you use online as B, DS4 is a lot more B than A. I can’t wait for the new releases, honestly (btw, thank you DeepSeek). So, after those chaotic first days, I hope the project will focus on: quality benchmarks, potentially adding a coding agent that is also part of the project, a hardware setup here in my home that can run the CI test in order to ensure long term quality, more ports, and finally but as a very important point: distributed inference (both serial and parallel). For now, thank you for all the support: it was really appreciated :) AI is too critical to be just a provided service. Comments
- Redis array type: short story of a long developmentMay 04, 2026
I started working on the new Array data type for Redis in the first days of January. The PR landed the repository only now, so this code was cooked for four months. I worked at the implementation kinda part time (kinda because many weeks were actually full time, sometimes to detach yourself from the keyboard is complicated), and even before LLMs the implementation was likely something I could do in four months. What changed is that in the same time span, I was able to do a lot more. This is the short story of what happened. In the first month I just wrote the specification document. The rationale for the new data type, the C structures, the sparse representation used, the exact semantics of the array cursor for ring buffer and ARINSERT. I started writing for days a long specification by hand, then I paired with Opus initially, then GPT 5.3 was released and I switched all the design and development with Codex. Since then I use only GPT 5.x for system programming tasks. Thanks to AI, the specification evolved a lot, via back and forth of feedback, intellectual challenges about what was the best design, what was the right compromise, what was too engineered and what not. Starting from the second month, I started the implementation using automatic programming (auto coding if you prefer), constantly reviewing the developed code. Then I realized that the level of indirection I picked was wrong. I really wanted people to be able to do ARSET myarray 293842948324 foo and everything to still work without huge allocations. The two levels of directory + slices (sparse and dense) I had were not enough. Because I had AI, I took no compromises, and I decided to go the extra mile. Once certain conditions are reached, the data structure internally changes shape, and becomes a super directory of sliced dense directories, that also point to the actual array slices (4096 elements per slice, by default). This design provided still the internal "is actually an array" representation I wanted, and the memory characteristics I seeked, while being able, for ARSCAN, and ARPOP, to scan the existing arrays taking a time proportional to the existing elements and not to the range span. Then, it was time to read all the code, line by line. Everything was working, and this type has massive testing, thanks, again to AI, but still things that superficially work do not mean they are optimal. I found many small inefficiencies or design errors that I didn't want, so I started a process of manual and AI-assisted rewrite of many modules. When this stage was done, I started, during the third month, to stress test the implementation in many different ways. I started to be confident that it was really solid, useful, well designed. Then… it happened. While modeling different use cases to see if the data structure was comfortable to use, I started to put markdown files into Redis arrays. Because files are a very good match for it. At this point, as I was working for other goals with agents, I realized that I could have the skills markdown files centralized knowledge base that I needed, so from a need of mine I decided to implement ARGREP. But I wanted regular expressions, too. What library to pick? I ended up picking TRE (thanks Ville Laurikari!), because when you have regexp in Redis, you want to be sure that there are no pathological patterns in time or space. But TRE was very inefficient in a specific and extremely useful case, that is matching foo|bar|zap. So with the help of GPT I optimized it, fixed a few potential security issues, and extended the test. I had everything in place. You know what was the biggest realization of all that? For high quality system programming tasks you have to still be fully involved, but I ventured to a level of complexity that I would have otherwise skipped. AI provided the safety net for two things: certain massive tasks that are very tiring (like the 32 bit support that was added and tested later), and at the same time the virtual work force required to make sure there are no obvious bugs in complicated algorithms. To write the initial huge specification was the key to the successive work, as it was the key to review each single line of sparsearray.c and t_array.c and modifying everything was not a good fit. I didn't spend any word on the use cases as I tried to document the PR itself with a message where they are detailed: https://github.com/redis/redis/pull/15162 So it was not really useful to repeat myself here. Enough to say that I really believe it is about time for Redis to have a data type where the numerical index is part of the semantics. I hope the Array PR will be accepted soon, and that we can benefit from the new use cases it opens. Of course, feedback is welcomed. Thank you. Comments
- AI cybersecurity is not proof of workApr 16, 2026
The proof of work is the wrong analogy: finding hash collisions, while exponentially harder with N, is guaranteed to find, with enough work, some S so that H(S) satisfies N, so an asymmetry of resources used will see the side with more "work ability" eventually winning. But bugs are different: 1. Different LLMs executions take different branches, but eventually the possible branches based on the code possible states are saturated. 2. If we imagine sampling the model for a bug in a given code M times, with M large, eventually the cap becomes not "M" (because of saturated state of the code AND the LLM sampler meaningful paths), but "I", the model intelligence level. The OpenBSD SACK bug easily shows that: you can run an inferior model for an infinite number of tokens, and it will never realize(*) that the lack of validation of the start window, if put together with the integer overflow, then put together with the fact the branch where the node should never be NULL is entered regardless, will produce the bug. So, cyber security of tomorrow will not be like proof of work in the sense of "more GPU wins"; instead, better models, and faster access to such models, will win. * Don't trust who says that weak models can find the OpenBSD SACK bug. I tried it myself. What happens is that weak models hallucinate (sometimes causally hitting a real problem) that there is a lack of validation of the start of the window (which is in theory harmless because of the start BTW, this is why with this bug, the stronger the model you pick (but not enough to discover the true bug), the less likely it is it will claim there is a bug. Stronger models hallucinate less, so they can't see the problem in any side of the spectrum: the hallucination side of small models, and the real understanding side of Mythos. Comments
- GNU and the AI reimplementationsMar 08, 2026
Those who cannot remember the past are condemned to repeat it. A sentence that I never really liked, and what is happening with AI, about software projects reimplementations, shows all the limits of such an idea. Many people are protesting the fairness of rewriting existing projects using AI. But, a good portion of such people, during the 90s, were already in the field: they followed the final part (started in the ‘80s) of the deeds of Richard Stallman, when he and his followers were reimplementing the UNIX userspace for the GNU project. The same people that now are against AI rewrites, back then, cheered for the GNU project actions (rightly, from my point of view – I cheered too). Stallman is not just a programming genius, he is also the kind of person that has a broad vision across disciplines, and among other things he was well versed in the copyright nuances. He asked the other programmers to reimplement the UNIX userspace in a specific way. A way that would make each tool unique, recognizable, compared to the original copy. Either faster, or more feature rich, or scriptable; qualities that would serve two different goals: to make GNU Hurd better and, at the same time, to provide a protective layer against litigations. If somebody would claim that the GNU implementations were not limited to copying ideas and behaviours (which is legal), but “protected expressions” (that is, the source code verbatim), the added features and the deliberate push towards certain design directions would provide a counter argument that judges could understand. He also asked to always reimplement the behavior itself, avoiding watching the actual implementation, using specifications and the real world mechanic of the tool, as tested manually by executing it. Still, it is fair to guess that many of the people working at the GNU project likely were exposed or had access to the UNIX source code. When Linus reimplemented UNIX, writing the Linux kernel, the situation was somewhat more complicated, with an additional layer of indirection. He was exposed to UNIX just as a user, but, apparently, had no access to the source code of UNIX. On the other hand, he was massively exposed to the Minix source code (an implementation of UNIX, but using a microkernel), and to the book describing such implementation as well. But, in turn, when Tanenbaum wrote Minix, he did so after being massively exposed to the UNIX source code. So, SCO (during the IBM litigation) had a hard time trying to claim that Linux contained any protected expressions. Yet, when Linus used Minix as an inspiration, not only was he very familiar with something (Minix) implemented with knowledge of the UNIX code, but (more interestingly) the license of Minix was restrictive, it became open source only in 2000. Still, even in such a setup, Tanenbaum protested about the architecture (in the famous exchange), not about copyright infringement. So, we could reasonably assume Tanenbaum considered rewrites fair, even if Linus was exposed to Minix (and having himself followed a similar process when writing Minix). # What the copyright law really says To put all this in the right context, let’s zoom in on the copyright's actual perimeters: the law says you must not copy “protected expressions”. In the case of the software, a protected expression is the code as it is, with the same structure, variables, functions, exact mechanics of how specific things are done, unless they are known algorithms (standard quicksort or a binary search can be implemented in a very similar way and they will not be a violation). The problem is when the business logic of the programs matches perfectly, almost line by line, the original implementation. Otherwise, the copy is lawful and must not obey the original license, as long as it is pretty clear that the code is doing something similar but with code that is not cut & pasted or mechanically translated to some other language, or aesthetically modified just to look a bit different (look: this is exactly the kind of bad-faith maneuver a court will try to identify). I have the feeling that every competent programmer reading this post perfectly knows what a *reimplementation* is and how it looks. There will be inevitable similarities, but the code will be clearly not copied. If this is the legal setup, why do people care about clean room implementations? Well, the reality is: it is just an optimization in case of litigation, it makes it simpler to win in court, but being exposed to the original source code of some program, if the exposition is only used to gain knowledge about the ideas and behavior, is fine. Besides, we are all happy to have Linux today, and the GNU user space, together with many other open source projects that followed a similar path. I believe rules must be applied both when we agree with their ends, and when we don’t. # AI enters the scene So, reimplementations were always possible. What changes, now, is the fact they are brutally faster and cheaper to accomplish. In the past, you had to hire developers, or to be enthusiastic and passionate enough to create a reimplementation yourself, because of business aspirations or because you wanted to share it with the world at large. Now, you can start a coding agent and proceed in two ways: turn the implementation into a specification, and then in a new session ask the agent to reimplement it, possibly forcing specific qualities, like: make it faster, or make the implementation incredibly easy to follow and understand (that’s a good trick to end with an implementation very far from others, given the fact that a lot of code seems to be designed for the opposite goal), or more modular, or resolve this fundamental limitation of the original implementation: all hints that will make it much simpler to significantly diverge from the original design. LLMs, when used in this way, don’t produce copies of what they saw in the past, but yet at the end you can use an agent to verify carefully if there is any violation, and if any, replace the occurrences with novel code. Another, apparently less rigorous approach, but potentially very good in the real world, is to provide the source code itself, and ask the agent to reimplement it in a completely novel way, and use the source code both as specification and in order to drive the implementation as far as possible away from the code itself. Frontier LLMs are very capable, they can use something even to explicitly avoid copying it, and carefully try different implementation approaches. If you ever attempted something like the above, you know how the “uncompressed copy” really is an illusion: agents will write the software in a very “organic” way, committing errors, changing design many times because of limitations that become clear only later, starting with something small and adding features progressively, and often, during this already chaotic process, we massively steer their work with our prompts, hints, wishes. Many ideas are consolatory as they are false: the “uncompressed copy” is one of those. But still, now the process of rewriting is so simple to do, and many people are disturbed by this. There is a more fundamental truth here: the nature of software changed; the reimplementations under different licenses are just an instance of how such nature was transformed forever. Instead of combatting each manifestation of automatic programming, I believe it is better to build a new mental model, and adapt. # Beyond the law I believe that organized societies prosper if laws are followed, yet I do not blindly accept a rule just because it exists, and I question things based on my ethics: this is what allows individuals, societies, and the law itself to evolve. We must ask ourselves: is the copyright law ethically correct? Does the speed-up AI provides to an existing process, fundamentally change the process itself? One thing that allowed software to evolve much faster than most other human fields is the fact the discipline is less anchored to patents and protections (and this, in turn, is likely as it is because of a sharing culture around the software). If the copyright law were more stringent, we could likely not have what we have today. Is the protection of single individuals' interests and companies more important than the general evolution of human culture? I don’t think so, and, besides, the copyright law is a common playfield: the rules are the same for all. Moreover, it is not a stretch to say that despite a more relaxed approach, software remains one of the fields where it is simpler to make money; it does not look like the business side was impacted by the ability to reimplement things. Probably, the contrary is true: think of how many businesses were made possible by an open source software stack (not that OSS is mostly made of copies, but it definitely inherited many ideas about past systems). I believe, even with AI, those fundamental tensions remain all valid. Reimplementations are cheap to make, but this is the new playfield for all of us, and just reimplementing things in an automated fashion, without putting something novel inside, in terms of ideas, engineering, functionalities, will have modest value in the long run. What will matter is the exact way you create something: Is it well designed, interesting to use, supported, somewhat novel, fast, documented and useful? Moreover, this time the inbalance of force is in the right direction: big corporations always had the ability to spend obscene amounts of money in order to copy systems, provide them in a way that is irresistible for users (free, for many years, for instance, to later switch model) and position themselves as leaders of ideas they didn’t really invent. Now, small groups of individuals can do the same to big companies' software systems: they can compete on ideas now that a synthetic workforce is cheaper for many. # We stand on the shoulders of giants There is another fundamental idea that we all need to internalize. Software is created and evolved as an incremental continuous process, where each new innovation is building on what somebody else invented before us. We are all very quick to build something and believe we “own” it, which is correct, if we stop at the exact code we wrote. But we build things on top of work and ideas already done, and given that the current development of IT is due to the fundamental paradigm that makes ideas and behaviors not covered by copyright, we need to accept that reimplementations are a fair process. If they don’t contain any novelty, maybe they are a lazy effort? That’s possible, yet: they are fair, and nobody is violating anything. Yet, if we want to be good citizens of the ecosystem, we should try, when replicating some work, to also evolve it, invent something new: to specialize the implementation for a lower memory footprint, or to make it more useful in certain contexts, or less buggy: the Stallman way. In the case of AI, we are doing, almost collectively, the error of thinking that a technology is bad or good for software and humanity in isolation. AI can unlock a lot of good things in the field of open source software. Many passionate individuals write open source because they hate their day job, and want to make something they love, or they write open source because they want to be part of something bigger than economic interests. A lot of open source software is either written in the free time, or with severe constraints on the amount of people that are allocated for the project, or - even worse - with limiting conditions imposed by the companies paying for the developments. Now that code is every day less important than ideas, open source can be strongly accelerated by AI. The four hours allocated over the weekend will bring 10x the fruits, in the right hands (AI coding is not for everybody, as good coding and design is not for everybody). Linux device drivers can be implemented by automatically disassembling some proprietary blob, for instance. Or, what could be just a barely maintained library can turn into a project that can be well handled in a more reasonable amount of time. Before AI, we witnessed the commodification of software: less quality, focus only on the money, no care whatsoever for minimalism and respect for resources: just piles of mostly broken bloat. More hardware power, more bloat, less care. It was already going very badly. It is not obvious nor automatic that AI will make it worse, and the ability to reimplement other software systems is part of a bigger picture that may restore some interest and sanity in our field. Comments
- Redis patterns for codingMar 01, 2026
Here LLM and coding agents can find: 1. Exhaustive documentation about Redis commands and data types. 2. Patterns commonly used. 3. Configuration hints. 4. Algorithms that can be mounted using Redis commands. https://redis.antirez.com/ Some humans claim this documentation is actually useful for actual people, as well :) I'm posting this to make sure search engines will index it. Comments
- Implementing a clear room Z80 / ZX Spectrum emulator with Claude CodeFeb 24, 2026
Anthropic recently released a blog post with the description of an experiment in which the last version of Opus, the 4.6, was instructed to write a C compiler in Rust, in a “clean room” setup. The experiment methodology left me dubious about the kind of point they wanted to make. Why not provide the agent with the ISA documentation? Why Rust? Writing a C compiler is exactly a giant graph manipulation exercise: the kind of program that is harder to write in Rust. Also, in a clean room experiment, the agent should have access to all the information about well established computer science progresses related to optimizing compilers: there are a number of papers that could be easily synthesized in a number of markdown files. SSA, register allocation, instructions selection and scheduling. Those things needed to be researched *first*, as a prerequisite, and the implementation would still be “clean room”. Not allowing the agent to access the Internet, nor any other compiler source code, was certainly the right call. Less understandable is the almost-zero steering principle, but this is coherent with a certain kind of experiment, if the goal was showcasing the completely autonomous writing of a large project. Yet, we all know how this is not how coding agents are used in practice, most of the time. Who uses coding agents extensively knows very well how, even never touching the code, a few hits here and there completely changes the quality of the result. # The Z80 experiment I thought it was time to try a similar experiment myself, one that would take one or two hours at max, and that was compatible with my Claude Code Max plan: I decided to write a Z80 emulator, and then a ZX Spectrum emulator (and even more, a CP/M emulator, see later) in a condition that I believe makes a more sense as “clean room” setup. The result can be found here: https://github.com/antirez/ZOT. # The process I used 1. I wrote a markdown file with the specification of what I wanted to do. Just English, high level ideas about the scope of the Z80 emulator to implement. I said things like: it should execute a whole instruction at a time, not a single clock step, since this emulator must be runnable on things like an RP2350 or similarly limited hardware. The emulator should correctly track the clock cycles elapsed (and I specified we could use this feature later in order to implement the ZX Spectrum contention with ULA during memory accesses), provide memory access callbacks, and should emulate all the known official and unofficial instructions of the Z80. For the Spectrum implementation, performed as a successive step, I provided much more information in the markdown file, like, the kind of rendering I wanted in the RGB buffer, and how it needed to be optional so that embedded devices could render the scanlines directly as they transferred them to the ST77xx display (or similar), how it should be possible to interact with the I/O port to set the EAR bit to simulate cassette loading in a very authentic way, and many other desiderata I had about the emulator. This file also included the rules that the agent needed to follow, like: * Accessing the internet is prohibited, but you can use the specification and test vectors files I added inside ./z80-specs. * Code should be simple and clean, never over-complicate things. * Each solid progress should be committed in the git repository. * Before committing, you should test that what you produced is high quality and that it works. * Write a detailed test suite as you add more features. The test must be re-executed at every major change. * Code should be very well commented: things must be explained in terms that even people not well versed with certain Z80 or Spectrum internals details should understand. * Never stop for prompting, the user is away from the keyboard. * At the end of this file, create a work in progress log, where you note what you already did, what is missing. Always update this log. * Read this file again after each context compaction. 2. Then, I started a Claude Code session, and asked it to fetch all the useful documentation on the internet about the Z80 (later I did this for the Spectrum as well), and to extract only the useful factual information into markdown files. I also provided the binary files for the most ambitious test vectors for the Z80, the ZX Spectrum ROM, and a few other binaries that could be used to test if the emulator actually executed the code correctly. Once all this information was collected (it is part of the repository, so you can inspect what was produced) I completely removed the Claude Code session in order to make sure that no contamination with source code seen during the search was possible. 3. I started a new session, and asked it to check the specification markdown file, and to check all the documentation available, and start implementing the Z80 emulator. The rules were to never access the Internet for any reason (I supervised the agent while it was implementing the code, to make sure this didn’t happen), to never search the disk for similar source code, as this was a “clean room” implementation. 4. For the Z80 implementation, I did zero steering. For the Spectrum implementation I used extensive steering for implementing the TAP loading. More about my feedback to the agent later in this post. 5. As a final step, I copied the repository in /tmp, removed the “.git” repository files completely, started a new Claude Code (and Codex) session and claimed that the implementation was likely stolen or too strongly inspired from somebody else's work. The task was to check with all the major Z80 implementations if there was evidence of theft. The agents (both Codex and Claude Code), after extensive search, were not able to find any evidence of copyright issues. The only similar parts were about well established emulation patterns and things that are Z80 specific and can’t be made differently, the implementation looked distinct from all the other implementations in a significant way. # Results Claude Code worked for 20 or 30 minutes in total, and produced a Z80 emulator that was able to pass ZEXDOC and ZEXALL, in 1200 lines of very readable and well commented C code (1800 lines with comments and blank spaces). The agent was prompted zero times during the implementation, it acted absolutely alone. It never accessed the internet, and the process it used to implement the emulator was of continuous testing, interacting with the CP/M binaries implementing the ZEXDOC and ZEXALL, writing just the CP/M syscalls needed to produce the output on the screen. Multiple times it also used the Spectrum ROM and other binaries that were available, or binaries it created from scratch to see if the emulator was working correctly. In short: the implementation was performed in a very similar way to how a human programmer would do it, and not outputting a complete implementation from scratch “uncompressing” it from the weights. Instead, different classes of instructions were implemented incrementally, and there were bugs that were fixed via integration tests, debugging sessions, dumps, printf calls, and so forth. # Next step: the ZX Spectrum I repeated the process again. I instructed the documentation gathering session very accurately about the kind of details I wanted it to search on the internet, especially the ULA interactions with RAM access, the keyboard mapping, the I/O port, how the cassette tape worked and the kind of PWM encoding used, and how it was encoded into TAP or TZX files. As I said, this time the design notes were extensive since I wanted this emulator to be specifically designed for embedded systems, so only 48k emulation, optional framebuffer rendering, very little additional memory used (no big lookup tables for ULA/Z80 access contention), ROM not copied in the RAM to avoid using additional 16k of memory, but just referenced during the initialization (so we have just a copy in the executable), and so forth. The agent was able to create a very detailed documentation about the ZX Spectrum internals. I provided a few .z80 images of games, so that it could test the emulator in a real setup with real software. Again, I removed the session and started fresh. The agent started working and ended 10 minutes later, following a process that really fascinates me, and that probably you know very well: the fact is, you see the agent working using a number of diverse skills. It is expert in everything programming related, so as it was implementing the emulator, it could immediately write a detailed instrumentation code to “look” at what the Z80 was doing step by step, and how this changed the Spectrum emulation state. In this respect, I believe automatic programming to be already super-human, not in the sense it is currently capable of producing code that humans can’t produce, but in the concurrent usage of different programming languages, system programming techniques, DSP stuff, operating system tricks, math, and everything needed to reach the result in the most immediate way. When it was done, I asked it to write a simple SDL based integration example. The emulator was immediately able to run the Jetpac game without issues, with working sound, and very little CPU usage even on my slow Dell Linux machine (8% usage of a single core, including SDL rendering). Once the basic stuff was working, I wanted to load TAP files directly, simulating cassette loading. This was the first time the agent missed a few things, specifically about the timing the Spectrum loading routines expected, and here we are in the territory where LLMs start to perform less efficiently: they can’t easily run the SDL emulator and see the border changing as data is received and so forth. I asked Claude Code to do a refactoring so that zx_tick() could be called directly and was not part of zx_frame(), and to make zx_frame() a trivial wrapper. This way it was much simpler to sync EAR with what it expected, without callbacks or the wrong abstractions that it had implemented. After such change, a few minutes later the emulator could load a TAP file emulating the cassette without problems. This is how it works now: do { zx_set_ear(zx, tzx_update(&tape, zx->cpu.clocks)); } while (!zx_tick(zx, 0)); I continued prompting Claude Code in order to make the key bindings more useful and a few things more. # CP/M One thing that I found really interesting was the ability of the LLM to inspect the COM files for ZEXALL / ZEXCOM tests for the Z80, easily spot the CP/M syscalls that were used (a total of three), and implement them for the extended z80 test (executed by make fulltest). So, at this point, why not implement a full CP/M environment? Same process again, same good result in a matter of minutes. This time I interacted with it a bit more for the VT100 / ADM3 terminal escapes conversions, reported things not working in WordStar initially, and in a few minutes everything I tested was working well enough (but, there are fixes to do, like simulating a 2Mhz clock, right now it runs at full speed making CP/M games impossible to use). # What is the lesson here? The obvious lesson is: always provide your agents with design hints and extensive documentation about what they are going to do. Such documentation can be obtained by the agent itself. And, also, make sure the agent has a markdown file with the rules of how to perform the coding tasks, and a trace of what it is doing, that is updated and read again quite often. But those tricks, I believe, are quite clear to everybody that has worked extensively with automatic programming in the latest months. To think in terms of “what a human would need” is often the best bet, plus a few LLMs specific things, like the forgetting issue after context compaction, the continuous ability to verify it is on the right track, and so forth. Returning back to the Anthropic compiler attempt: one of the steps that the agent failed was the one that was more strongly related to the idea of memorization of what is in the pretraining set: the assembler. With extensive documentation, I can’t see any way Claude Code (and, even more, GPT5.3-codex, which is in my experience, for complex stuff, more capable) could fail at producing a working assembler, since it is quite a mechanical process. This is, I think, in contradiction with the idea that LLMs are memorizing the whole training set and uncompress what they have seen. LLMs can memorize certain over-represented documents and code, but while they can extract such verbatim parts of the code if prompted to do so, they don’t have a copy of everything they saw during the training set, nor they spontaneously emit copies of already seen code, in their normal operation. We mostly ask LLMs to create work that requires assembling different knowledge they possess, and the result is normally something that uses known techniques and patterns, but that is new code, not constituting a copy of some pre-existing code. It is worth noting, too, that humans often follow a less rigorous process compared to the clean room rules detailed in this blog post, that is: humans often download the code of different implementations related to what they are trying to accomplish, read them carefully, then try to avoid copying stuff verbatim but often times they take strong inspiration. This is a process that I find perfectly acceptable, but it is important to take in mind what happens in the reality of code written by humans. After all, information technology evolved so fast even thanks to this massive cross pollination effect. For all the above reasons, when I implement code using automatic programming, I don’t have problems releasing it MIT licensed, like I did with this Z80 project. In turn, this code base will constitute quality input for the next LLMs training, including open weights ones. # Next steps To make my experiment more compelling, one should try to implement a Z80 and ZX Spectrum emulator without providing any documentation to the agent, and then compare the result of the implementation. I didn’t find the time to do it, but it could be quite informative. Comments
- Automatic programmingJan 31, 2026
In my YouTube channel, for some time now I started to refer to the process of writing software using AI assistance (soon to become just "the process of writing software", I believe) with the term "Automatic Programming". In case you didn't notice, automatic programming produces vastly different results with the same LLMs depending on the human that is guiding the process with their intuition, design, continuous steering and idea of software. Please, stop saying "Claude vibe coded this software for me". Vibe coding is the process of generating software using AI without being part of the process at all. You describe what you want in very general terms, and the LLM will produce whatever happens to be the first idea/design/code it would spontaneously, given the training, the specific sampling that happened to dominate in that run, and so forth. The vibe coder will, at most, report things not working or not in line with what they expected. When the process is actual software production where you know what is going on, remember: it is the software *you* are producing. Moreover remember that the pre-training data, while not the only part where the LLM learns (RL has its big weight) was produced by humans, so we are not appropriating something else. We can pretend AI generated code is "ours", we have the right to do so. Pre-training is, actually, our collective gift that allows many individuals to do things they could otherwise never do, like if we are now linked in a collective mind, in a certain way. That said, if vibe coding is the process of producing software without much understanding of what is going on (which has a place, and democratizes software production, so it is totally ok with me), automatic programming is the process of producing software that attempts to be high quality and strictly following the producer's vision of the software (this vision is multi-level: can go from how to do, exactly, certain things, at a higher level, to stepping in and tell the AI how to write a certain function), with the help of AI assistance. Also a fundamental part of the process is, of course, *what* to do. I'm a programmer, and I use automatic programming. The code I generate in this way is mine. My code, my output, my production. I, and you, can be proud. If you are not completely convinced, think to Redis. In Redis there is not much technical novelty, especially at its start it was just a sum of basic data structures and networking code that every competent system programmer could write. So, why it became a very useful piece of software? Because of the ideas and visions it contained. Programming is now automatic, vision is not (yet). Comments
- Don't fall into the anti-AI hypeJan 11, 2026
I love writing software, line by line. It could be said that my career was a continuous effort to create software well written, minimal, where the human touch was the fundamental feature. I also hope for a society where the last are not forgotten. Moreover, I don't want AI to economically succeed, I don't care if the current economic system is subverted (I could be very happy, honestly, if it goes in the direction of a massive redistribution of wealth). But, I would not respect myself and my intelligence if my idea of software and society would impair my vision: facts are facts, and AI is going to change programming forever. In 2020 I left my job in order to write a novel about AI, universal basic income, a society that adapted to the automation of work facing many challenges. At the very end of 2024 I opened a YouTube channel focused on AI, its use in coding tasks, its potential social and economical effects. But while I recognized what was going to happen very early, I thought that we had more time before programming would be completely reshaped, at least a few years. I no longer believe this is the case. Recently, state of the art LLMs are able to complete large subtasks or medium size projects alone, almost unassisted, given a good set of hints about what the end result should be. The degree of success you'll get is related to the kind of programming you do (the more isolated, and the more textually representable, the better: system programming is particularly apt), and to your ability to create a mental representation of the problem to communicate to the LLM. But, in general, it is now clear that for most projects, writing the code yourself is no longer sensible, if not to have fun. In the past week, just prompting, and inspecting the code to provide guidance from time to time, in a few hours I did the following four tasks, in hours instead of weeks: 1. I modified my linenoise library to support UTF-8, and created a framework for line editing testing that uses an emulated terminal that is able to report what is getting displayed in each character cell. Something that I always wanted to do, but it was hard to justify the work needed just to test a side project of mine. But if you can just describe your idea, and it materializes in the code, things are very different. 2. I fixed transient failures in the Redis test. This is very annoying work, timing related issues, TCP deadlock conditions, and so forth. Claude Code iterated for all the time needed to reproduce it, inspected the state of the processes to understand what was happening, and fixed the bugs. 3. Yesterday I wanted a pure C library that would be able to do the inference of BERT like embedding models. Claude Code created it in 5 minutes. Same output and same speed (15% slower) than PyTorch. 700 lines of code. A Python tool to convert the GTE-small model. 4. In the past weeks I operated changes to Redis Streams internals. I had a design document for the work I did. I tried to give it to Claude Code and it reproduced my work in, like, 20 minutes or less (mostly because I'm slow at checking and authorizing to run the commands needed). It is simply impossible not to see the reality of what is happening. Writing code is no longer needed for the most part. It is now a lot more interesting to understand what to do, and how to do it (and, about this second part, LLMs are great partners, too). It does not matter if AI companies will not be able to get their money back and the stock market will crash. All that is irrelevant, in the long run. It does not matter if this or the other CEO of some unicorn is telling you something that is off putting, or absurd. Programming changed forever, anyway. How do I feel, about all the code I wrote that was ingested by LLMs? I feel great to be part of that, because I see this as a continuation of what I tried to do all my life: democratizing code, systems, knowledge. LLMs are going to help us to write better software, faster, and will allow small teams to have a chance to compete with bigger companies. The same thing open source software did in the 90s. However, this technology is far too important to be in the hands of a few companies. For now, you can do the pre-training better or not, you can do reinforcement learning in a much more effective way than others, but the open models, especially the ones produced in China, continue to compete (even if they are behind) with frontier models of closed labs. There is a sufficient democratization of AI, so far, even if imperfect. But: it is absolutely not obvious that it will be like that forever. I'm scared about the centralization. At the same time, I believe neural networks, at scale, are simply able to do incredible things, and that there is not enough "magic" inside current frontier AI for the other labs and teams not to catch up (otherwise it would be very hard to explain, for instance, why OpenAI, Anthropic and Google are so near in their results, for years now). As a programmer, I want to write more open source than ever, now. I want to improve certain repositories of mine abandoned for time concerns. I want to apply AI to my Redis workflow. Improve the Vector Sets implementation and then other data structures, like I'm doing with Streams now. But I'm worried for the folks that will get fired. It is not clear what the dynamic at play will be: will companies try to have more people, and to build more? Or will they try to cut salary costs, having fewer programmers that are better at prompting? And, there are other sectors where humans will become completely replaceable, I fear. What is the social solution, then? Innovation can't be taken back after all. I believe we should vote for governments that recognize what is happening, and are willing to support those who will remain jobless. And, the more people get fired, the more political pressure there will be to vote for those who will guarantee a certain degree of protection. But I also look forward to the good AI could bring: new progress in science, that could help lower the suffering of the human condition, which is not always happy. Anyway, back to programming. I have a single suggestion for you, my friend. Whatever you believe about what the Right Thing should be, you can't control it by refusing what is happening right now. Skipping AI is not going to help you or your career. Think about it. Test these new tools, with care, with weeks of work, not in a five minutes test where you can just reinforce your own beliefs. Find a way to multiply yourself, and if it does not work for you, try again every few months. Yes, maybe you think that you worked so hard to learn coding, and now machines are doing it for you. But what was the fire inside you, when you coded till night to see your project working? It was building. And now you can build more and better, if you find your way to use AI effectively. The fun is still there, untouched. Comments
- Reflections on AI at the end of 2025Dec 20, 2025
* For years, despite functional evidence and scientific hints accumulating, certain AI researchers continued to claim LLMs were stochastic parrots: probabilistic machines that would: 1. NOT have any representation about the meaning of the prompt. 2. NOT have any representation about what they were going to say. In 2025 finally almost everybody stopped saying so. * Chain of thought is now a fundamental way to improve LLM output. But, what is CoT? Why it improves output? I believe it is two things: 1. Sampling in the model representations (that is, a form of internal search). After information and concepts relevant to the prompt topic is in the context window, the model can better reply. 2. But if you mix this to reinforcement learning, the model also learns to put one token after the other (each token will change the model state) in order to converge to some useful reply. * The idea that scaling is limited to the number of tokens we have, is no longer true, because of reinforcement learning with verifiable rewards. We are still not at AlphaGo move 37 moment, but is this really impossible in the future? There are certain tasks, like improving a given program for speed, for instance, where in theory the model can continue to make progress with a very clear reward signal for a very long time. I believe improvements to RL applied to LLMs will be the next big thing in AI. * Programmers resistance to AI assisted programming has lowered considerably. Even if LLMs make mistakes, the ability of LLMs to deliver useful code and hints improved to the point most skeptics started to use LLMs anyway: now the return on the investment is acceptable for many more folks. The programming world is still split among who uses LLMs as colleagues (for instance, all my interaction is via the web interface of Gemini, Claude, …), and who uses LLMs as independent coding agents. * A few well known AI scientists believe that what happened with Transformers can happen again, and better, following different paths, and started to create teams, companies to investigate alternatives to Transformers and models with explicit symbolic representations or world models. I believe that LLMs are differentiable machine trained on a space able to approximate discrete reasoning steps, and it is not impossible they get us to AGI even without fundamentally new paradigms appearing. It is likely that AGI can be reached independently with many radically different architectures. * There is who says chain of thought changed LLMs nature fundamentally, and this is why they, in the past, claimed LLMs were very limited, and now are changing their mind. They say, because of CoT, LLMs are now a different thing. They are lying. It is still the same architecture with the same next token target, and the CoT is created exactly like that, token after token. * The ARC test today looks a lot less insurmountable than initially thought: there are small models optimized for the task at hand that perform decently well on ARC-AGI-1, and very large LLMs with extensive CoT achieving impressive results on ARC-AGI-2 with an architecture that, according to many folks, would not deliver such results. ARC, in some way, transitioned from being the anti-LLM test to a validation of LLMs. * The fundamental challenge in AI for the next 20 years is avoiding extinction. Comments
- Scaling HNSWsNov 11, 2025
I’m taking a few weeks of pause on my HNSWs developments (now working on some other data structure, news soon). At this point, the new type I added to Redis is stable and complete enough, it’s the perfect moment to reason about what I learned about HNSWs, and turn it into a blog post. That kind of brain dump that was so common pre-AI era, and now has become, maybe, a bit more rare. Well, after almost one year of thinking and implementing HNSWs and vector similarity stuff, it is time for some writing. However this is not going to be an intro on HNSWs: too many are present already. This is the “extra mile” instead. If you know HNSWs, I want to share with you my more “advanced” findings, especially in the context of making them fast enough to allow for a “Redis” experience: you know, Redis is designed for low latency and high performance, and HNSWs are kinda resistant to that, so there were challenges to expose HNSWs as an abstract data structure. This blog post will be split into several sections. Think of them as pages of the same book, different chapters of the same experience. Oh and, by the way, I already wrote and subsequently lost this blog post :D [long, sad story about MacOS and bad habits – I hadn’t lost something like that since the 90s, during blackouts], so here most of the problem will be to recall what I wrote a few days ago and, while I’m at it, to better rephrase what I didn’t like very much. ## A few words about the state of HNSW Before digging into the HNSWs internals and optimizations, I want to say a few things about HNSWs. The original paper introducing HNSWs is a great piece of computer science literature, and HNSWs are amazing data structures, but: I don’t believe they are the last word for searching, in a greedy way, for nearby vectors according to a distance function. The paper gives the feeling it lacks some “pieces”, almost like if the researchers, given six months more, had a lot more to explore and say. For instance, I modified the paper myself, extending it in order to support removal of entries, actual removals, not just tombstone deletions where the element is marked as gone and collected later: deleting items is totally missing from the paper. Similarly, there are, right now, efforts in order to really check if the “H” in the HNSWs is really needed, and if instead a flat data structure with just one layer would perform more or less the same (I hope I’ll cover more about this in the future: my feeling is that the truth is in the middle, and that it makes sense to modify the level selection function to just have levels greater than a given threshold). All this to say that, if you are into data structures research, I believe that a great area is to imagine evolutions and refinements of HNSWs, without getting trapped within the idea that the evolutions are only in the sense of: let’s do it, but for disk (see Microsoft efforts), or the like. Ok, enough with the premise, let’s go to the actual low level stuff :) ## Scaling memory Redis is an in-memory system, and both HNSWs and vectors have the unfortunate quality of being very space-hungry. There are three reasons for this: 1. HNSWs have a lot of pointers, like 16, 32 or more pointers (this is a tunable parameter of HNSWs) to neighbor nodes. 2. HNSWs have many levels, being a skiplist-alike data structure. This exacerbates the first problem. 3. HNSW’s satellite data is a vector of floating point numbers, so, in the vanilla case, 4 bytes per component, and normally you can have 300-3000 components, this is the usual range. So, what are the lessons learned here? There are folks that compress pointers, since it is very likely that many pointers (8 bytes in 64 bit systems) will have the highest four bytes all the same. This is smart, I didn’t implement it yet, because in Redis I need to go fast, and this is a tradeoff between space and time: but maybe it is worth it, maybe not. I’ll dig more. However, if you do the math, the fact that there are many layers is not *so* terrible as it looks. On average, the multiple layers per node make the situation worse by just ~1.3x (if the probability of level increase is 0.25 in the level selection function), since many nodes will be just at layer 0. But still 1.3 is more than 1, and if that “H” in HNSWs really is not *so* useful… [Spoiler, what I found is that the seek time if you have everything at layer 0 is greater, the main loop for the greedy search will start from less optimal places and it will eventually reach the right cluster, but will take more computation time. However this is just early results.] So here the *real* low hanging fruit is: vector quantization. What I found is that if you use 8 bit quantization what you get is an almost 4x speedup, a 4x reduction of your vectors (but not a 4x reduction of the whole node: the pointers are still there, and they take a lot of space), and a recall that is virtually the same in real world use cases. This is the reason why Redis Vector Sets use 8 bit quantization by default. You can specify, via VADD options, that you want full precision vectors or binary quantized vectors, where we just take the sign, but I’m skeptical about using both full size vectors and binary quantized vectors. Before talking about them, let’s see what kind of quantization I used for 8 bit. What I do is to compute the maximum absolute value of the component of each vector (so quantization is per-vector), then I use signed 8 bit values to represent the quant from -127 to 127. This is not as good as storing both min and max value, but it is faster when computing cosine similarity, since I can do this: /* Each vector is quantized from [-max_abs, +max_abs] to [-127, 127] * where range = 2*max_abs. */ const float scale_product = (range_a/127) * (range_b/127); Then I multiply things together in the integer domain with (actually in the code the main loop is unrolled and uses multiple accumulators, to make modern CPUs more busy) for (; i And finally we can return back to the floating point distance with: float dotf = dot0 * scale_product; Check the vectors_distance_q8() for more information, but I believe you got the idea: it is very simple to go from the integer quants domain to the unquantized dotproduct with trivial operations. So, 8 bit quantization is a great deal, and full precision was a *needed* feature, because there will be people doing things with vectors generated in a way where each small amount makes a difference (no, with learned vectors this is not the case…) but, why binary quantization? Because I wanted users to have a simple way to not waste space when their *original* information is already binary. Imagine you have a set of users and they have yes/no properties, and you want to find similar users, items, whatever. Well: this is where binary quantization should be used, it’s just, again, an option of the VADD command. ## Scaling speed: threading and locality Oh, you know, I have to tell you something about myself: I’m not a fan of threaded systems when it is possible to do a lot with a single core, and then use multiple cores in a shared-nothing architecture. But HNSWs are different. They are *slow*, and they are accessed almost always in read-only ways, at least in most use cases. For this reason, my Vector Sets implementation is fully threaded. Not just reads, even writes are partially threaded, and you may wonder how this is possible without it resulting in a mess, especially in a system like Redis, where keys can be accessed in different ways by the background saving process, the clients, and so forth. Well, to start, let’s focus on reads. What happens is that as long as nobody is writing in the data structure, we can spawn threads that do the greedy collection of near vectors and return back the results to the blocked client. However, my implementation of HNSWs was written from scratch, I mean, from the empty C file opened with vim, it has 0% of shared code with the two implementations most other systems use, so there are a few “novelties”. One of such different things is that in order to avoid re-visiting already visited nodes, I use an integer stored in each node that is called “epoch”, instead of using another data structure to mark (like, in a hash table) nodes already visited. This is quite slow, I believe. The epoch instead is local to the node, and the global data structure increments the epoch for each search. So in the context of each search, we are sure that we can find epochs that are just But with threads, there are multiple searches occurring at the same time! And, yep, what I needed was an array of epochs: typedef struct hnswNode { uint32_t level; /* Node's maximum level */ … many other stuff … uint64_t visited_epoch[HNSW_MAX_THREADS]; } That’s what you can read in hnsw.h. This is, again, a space-time tradeoff, and again time won against space. So, how was it possible to have threaded writes? The trick is that in HNSW inserts, a lot of time is spent looking for neighbors candidates. So writes are split into a reading-half and commit-half, only the second needs a write lock, and there are a few tricks to make sure that the candidates we accumulated during the first part are discarded if the HNSW changed in the meantime, and some nodes may no longer be valid. There is, however, another problem. What about the user deleting the key, while background threads are working on the value? For this scenario, we have a function that waits for background operations to return before actually reclaiming the object. With these tricks, it is easy to get 50k ops/sec on real world vector workloads, and these are numbers I got from redis-benchmark itself, with all the overhead involved. The raw numbers of the flat HNSW library itself are much higher. ## Scaling memory: reclaiming it properly Before talking about how to scale HNSWs into big use cases with multiple instances involved, and why Redis Vector Sets expose the actual data structure in the face of the user (I believe programmers are smart and don’t need babysitting, but it’s not *just* that), I want to go back and talk again about memory, because there is an interesting story to tell about this specific aspect. Most HNSWs implementations are not able to reclaim memory directly when you delete a node from the graph. I believe there are two main reasons for that: 1. People misunderstand the original HNSW paper in a specific way: they believe links can be NOT reciprocal among neighbors. And there is a specific reason why they think so. 2. The paper does not say anything about deletion of nodes and how to fix the graph after nodes go away and we get missing links in the “web” of connections. The first problem is a combination (I believe) of lack of clarity in the paper and the fact that, while implementing HNSWs, people face a specific problem: when inserting a new node, and good neighbors are searched among existing nodes, often the candidates already have the maximum number of outgoing links. What to do, in this case? The issue is often resolved by linking unidirectionally from the new node we are inserting to the candidates that are already “full” of outgoing links. However, when you need to delete a node, you can no longer resolve all its incoming links, so you can’t really reclaim memory. You mark it as deleted with a flag, and later sometimes there is some rebuilding of the graph to “garbage collect” stale nodes, sometimes memory is just leaked. So, to start, my implementation in Redis does things differently by forcing links to be bidirectional. If A links to B, B links to A. But, how to do so, given that A may be busy? Well, this gets into complicated territory but what happens is that heuristics are used in order to drop links from existing nodes, with other neighbors that are well connected, and if our node is a better candidate even for the target node, and if this is not true there are other ways to force a new node to have at least a minimal number of links, always trying to satisfy the small world property of the graph. This way, when Redis deletes a node from a Vector Set, it always has a way to remove all the pointers to it. However, what to do with the remaining nodes that now are missing a link? What I do is to create a distance matrix among them, in order to try to link the old node neighbors among them, trying to minimize the average distance. Basically for each pair of i,j nodes in our matrix, we calculate how good is their connection (how similar their vectors are) and how badly linking them affects the *remaining* possible pairs (since there could be elements left without good pairs, if we link two specific nodes). After we build this matrix of scores, we then proceed with a greedy pairing step. This works so well that you can build a large HNSW with millions of elements, later delete 95% of all your elements, and the remaining graph still has good recall and no isolated nodes and so forth. That is what I mean when I say that there is space in HNSWs for new papers to continue the work. ## Scaling HNSWs to multiple processes When I started to work at Redis Vector Sets, there was already a vector similarity implementation in Redis-land, specifically as an index type of RediSearch, and this is how most people think at HNSWs: a form of indexing of existing data. Yet I wanted to provide Redis with a new HNSW implementation exposed in a completely different way. Guess how? As a data structure, of course. And this tells a story about how Redis-shaped is my head after so many years, or maybe it was Redis-shaped since the start, and it is Redis that is shaped after my head, since I immediately envisioned how to design a Redis data structure that exposed HNSWs to the users, directly, and I was puzzled that the work with vectors in Redis was not performed exactly like that. At the same time, when I handed my design document to my colleagues at Redis, I can’t say that they immediately “saw” it as an obvious thing. My reasoning was: vectors are like scores in Redis Sorted Sets, except they are not scalar scores where you have a total order. Yet you can VADD, VREM, elements, and then you can call VSIM instead of ZRANGE in order to have *similar* elements. This made sense not just as an API, but I thought of HNSWs as strongly composable, and not linked to a specific use case (not specific to text embeddings, or image embeddings, or even *learned* embeddings necessarily). You do: VADD my_vector_set VALUES [… components …] my_element_string So whatever is in your components, Redis doesn't care, when you call VSIM it will report similar elements. But this also means that, if you have different vectors about the same use case split in different instances / keys, you can ask VSIM for the same query vector into all the instances, and add the WITHSCORES option (that returns the cosine distance) and merge the results client-side, and you have magically scaled your hundred of millions of vectors into multiple instances, splitting your dataset N times [One interesting thing about such a use case is that you can query the N instances in parallel using multiplexing, if your client library is smart enough]. Another very notable thing about HNSWs exposed in this raw way, is that you can finally scale writes very easily. Just hash your element modulo N, and target the resulting Redis key/instance. Multiple instances can absorb the (slow, but still fast for HNSW standards) writes at the same time, parallelizing an otherwise very slow process. This way of exposing HNSWs also scales down in a very significant way: sometimes you want an HNSW for each user / item / product / whatever you are working with. This is very hard to model if you have an index on top of something, but it is trivial if your HNSWs are data structures. You just can have a Vector Set key for each of your items, with just a handful of elements. And of course, like with any other Redis key, you can set an expiration time on the key, so that it will be removed automatically later. All this can be condensed into a rule that I believe should be more present in our industry: many programmers are smart, and if instead of creating a magic system they have no access to, you show them the data structure, the tradeoffs, they can build more things, and model their use cases in specific ways. And your system will be simpler, too. ## Scaling loading times If I don’t use threading, my HNSW library can add word2vec (300 components for each vector) into an HNSW at 5000 elements/second if I use a single thread, and can query the resulting HNSW at 90k queries per second. As you can see there is a large gap. This means that loading back an HNSW with many millions of elements from a Redis dump file into memory would take a lot of time. And this time would impact replication as well. Not great. But, this is true only if we add elements from the disk to the memory in the most trivial way, that is storing “element,vector” on disk and then trying to rebuild the HNSW in memory. There is another lesson to learn here. When you use HNSWs, you need to serialize the nodes and the neighbors as they are, so you can rebuild everything in memory just allocating stuff and turning neighbors IDs into pointers. This resulted in a 100x speedup. But do you really believe the story ends here? Hehe. Recently Redis has stronger security features and avoids doing bad things even when the RDB file is corrupted by an attacker. So what I needed to do was to make sure the HNSW is valid after loading, regardless of the errors and corruption in the serialized data structure. This involved many tricks, but I want to take the freedom to just dump one comment I wrote here, as I believe the reciprocal check is particularly cool: /* Second pass: fix pointers of all the neighbors links. * As we scan and fix the links, we also compute the accumulator * register "reciprocal", that is used in order to guarantee that all * the links are reciprocal. * * This is how it works, we hash (using a strong hash function) the * following key for each link that we see from A to B (or vice versa): * * hash(salt || A || B || link-level) * * We always sort A and B, so the same link from A to B and from B to A * will hash the same. Then we xor the result into the 128 bit accumulator. * If each link has its own backlink, the accumulator is guaranteed to * be zero at the end. * * Collisions are extremely unlikely to happen, and an external attacker * can't easily control the hash function output, since the salt is * unknown, and also there would be to control the pointers. * * This algorithm is O(1) for each node so it is basically free for * us, as we scan the list of nodes, and runs on constant and very * small memory. */ ## Scaling use cases: JSON filters I remember the day when the first working implementation of Vector Sets felt complete. Everything worked as expected and it was the starting point to start with the refinements and the extra features. However in the past weeks and months I internally received the feedback that most use cases need some form of mixed search: you want near vectors to a given query vector (like most similar movies to something) but also with some kind of filtering (only released between 2000 and 2010). My feeling is that you need to query for different parameters less often than product people believe, and that most of the time you can obtain this more efficiently by adding, in this specific case, each year to a different vector set key (this is another instance of the composability of HNSWs expressed as data structures versus a kind of index). However I was thinking about the main loop of the HNSW greedy search, that is something like this: // Simplified HNSW greedy search algorithm. Don’t trust it too much. while(candidates.len() > 0) { c = candidates.pop_nearest(query); worst_distance = results.get_worst_dist(query); if (distance(query,c) > worst_distance) break; foreach (neighbor from c) { if (neighbor.already_visited()) continue; neighbor.mark_as_visited(); if (results.has_space() OR neighbor.distance(query) candidates.add(neighbor); results.add(neighbor); } } } return results; So I started to play with the idea of adding a JSON set of metadata for each node. What if, once I have things like {“year”: 1999}, this was enough to filter while I perform the greedy search? Sure, the search needed to be bound, but there is a key insight here: I want, to start, elements that are *near* to the query vector, so I don’t really need to explore the whole graph if the condition on the JSON attributes is not satisfied by many nodes. I’ll let the user specify the effort, and anyway very far away results that match the filter are useless. So that’s yet another way how my HNSW differs: it supports filtering by expressions similar to the ones you could write inside an “if” statement of a programming language. And your elements in the Vector Set can be associated with JSON blobs, expressing their properties. Then you can do things like: VSIM movies VALUES … your vector components here… FILTER '.year >= 1980 and .year ## A few words on memory usage HNSW’s fatal issue is — in theory — that they are normally served from memory. Actually, you can implement HNSWs on disk, even if there are better data structures from the point of view of disk access latencies. However, in the specific case of Redis and Vector Sets the idea is to provide something that is very fast, easy to work with: the flexibility of in-memory data structures help with that. So the question boils down to: is the memory usage really so bad? Loading the 3 million Word2Vec entries into Redis with the default int8 quantization takes 3GB of RAM, 1kb for each entry. Many use cases have just a few tens of million of entries, or a lot less. And what you get back from HNSWs, if well implemented, and in memory, is very good performance, which is crucial in a data structure and in a workload that is in itself slow by definition. In my MacBook I get 48k ops per second with redis-benchmark and VSIM against this key (holding the word2vec dataset). My feeling is that the memory usage of in-memory HNSWs is very acceptable for many use cases. And even in the use cases where you want the bulk of your vectors on disk, even if there is to pay for slower performance, your hot set should likely be served from RAM. This is one of the reasons why I believe that, to be active in HNSW research is a good idea: I don’t think they will be replaced anytime soon for most use cases. It seems more likely that we will continue to have different data structures that are ideal for RAM and for disk depending on the use cases and data size. Moreover, what I saw recently, even just scanning the Hacker News front page, is people with a few millions of items fighting with systems that are slower or more complicated than needed. HNSWs and carefully exposing them in the right way can avoid all that. ## Conclusions I like HNSWs, and working and implementing them was a real pleasure. I believe vectors are a great fit for Redis, even in an AI-less world (for instance, a few months ago I used them in order to fingerprint Hacker News users, replicating an old work published on HN in the past). HNSWs are simply too cool and powerful for a number of use cases, and with AI, and learned embeddings, all this escalates to a myriad of potential use cases. However, like most features in Redis, I expect that a lot of time will pass before people realize they are useful and powerful and how to use them (no, it’s not just a matter of RAG). This happened also with Streams: finally there is mass adoption, after so many years. If instead you are more interested in HNSW and the implementation I wrote, I believe the code is quite accessible, and heavily commented: https://github.com/redis/redis/blob/unstable/modules/vector-sets/hnsw.c If you want to learn more about Redis Vector Sets, please feel free to read the README file I wrote myself. There is also the official Redis documentation, but I suggest you start from here: https://github.com/redis/redis/tree/unstable/modules/vector-sets Thanks for reading such a long blog post! And have a nice day. References. This is the paper about the "H" in HNSW and how useful it is -> https://arxiv.org/abs/2412.01940 Comments
- AI is differentAug 13, 2025
Regardless of their flaws, AI systems continue to impress with their ability to replicate certain human skills. Even if imperfect, such systems were a few years ago science fiction. It was not even clear that we were so near to create machines that could understand the human language, write programs, and find bugs in a complex code base: bugs that escaped the code review of a competent programmer. Since LLMs and in general deep models are poorly understood, and even the most prominent experts in the field failed miserably again and again to modulate the expectations (with incredible errors on both sides: of reducing or magnifying what was near to come), it is hard to tell what will come next. But even before the Transformer architecture, we were seeing incredible progress for many years, and so far there is no clear sign that the future will not hold more. After all, a plateau of the current systems is possible and very credible, but it would likely stimulate, at this point, massive research efforts in the next step of architectures. However, if AI avoids plateauing long enough to become significantly more useful and independent of humans, this revolution is going to be very unlike the past ones. Yet the economic markets are reacting as if they were governed by stochastic parrots. Their pattern matching wants that previous technologies booms created more business opportunities, so investors are polarized to think the same will happen with AI. But this is not the only possible outcome. We are not there, yet, but if AI could replace a sizable amount of workers, the economic system will be put to a very hard test. Moreover, companies could be less willing to pay for services that their internal AIs can handle or build from scratch. Nor is it possible to imagine a system where a few mega companies are the only providers of intelligence: either AI will be eventually a commodity, or the governments would do something, in such an odd economic setup (a setup where a single industry completely dominates all the others). The future may reduce the economic prosperity and push humanity to switch to some different economic system (maybe a better system). Markets don’t want to accept that, so far, and even if the economic forecasts are cloudy, wars are destabilizing the world, the AI timings are hard to guess, regardless of all that stocks continue to go up. But stocks are insignificant in the vast perspective of human history, and even systems that lasted a lot more than our current institutions eventually were eradicated by fundamental changes in the society and in the human knowledge. AI could be such a change. Comments
- Coding with LLMs in the summer of 2025 (an update)Jul 20, 2025
Frontier LLMs such as Gemini 2.5 PRO, with their vast understanding of many topics and their ability to grasp thousands of lines of code in a few seconds, are able to extend and amplify the programmer capabilities. If you are able to describe problems in a clear way and, if you are able to accept the back and forth needed in order to work with LLMs, you can reach incredible results such as: 1. Eliminating bugs you introduced in your code before it ever hits any user: I experienced this with Vector Sets implementation of Redis. I would end eliminating all the bugs eventually, but many were just removed immediately by Gemini / Claude code reviews. 2. Explore faster how a given idea could work, by letting the LLM write the throw away code to test ASAP in order to see if a given solution is actually more performant, if it is good enough, and so forth. 3. Engage in pair-design activities where your instinct, experience, design taste can be mixed with the PhD-level knowledge encoded inside the LLM. In this activity, the LLM will sometimes propose stupid paths, other times incredibly bright ideas: you, the human, are there in order to escape local minimal and mistakes, and exploit the fact your digital friend knows of certain and various things more than any human can. 4. Accelerate your work by writing part of the code under your clear specifications. 5. Work with technologies far from your expertise but contiguous with what you can do (for instance: coding in 68000 assembly for an Amiga demo?) using LLMs as an extension of specific parts of your mind, for the knowledge you don't have. One and half years ago I wrote a blog post called “LLMs and programming in the first days of 2024”. There, I found LLMs to be already useful, but during these 1.5 years, the progresses they made completely changed the game. However, in order to leverage their capabilities, humans interacting with LLMs must have certain qualities and follow certain practices. Let’s explore them. ## Refuse vibe coding most of the times In this historical moment, LLMs are good amplifiers and bad one-man-band workers. There are still small throwaway projects where letting the LLM write all the code makes sense, like tests, small utilities of a few hundreds lines of codes. But while LLMs can write part of a code base with success (under your strict supervision, see later), and produce a very sensible speedup in development (or, the ability to develop more/better in the same time used in the past — which is what I do), when left alone with nontrivial goals they tend to produce fragile code bases that are larger than needed, complex, full of local minima choices, suboptimal in many ways. Moreover they just fail completely when the task at hand is more complex than a given level. Tomorrow all this may change, but right now after daily experience writing code with LLMs I strongly believe the maximum quality of work is reached using the human+LLM equation. I believe that humans and LLMs together are more productive than just humans, but this requires a big “if”, that is, if such humans have extensive communication capabilities and LLMs experiences: the ability to communicate efficiently is a key factor in using LLMs. ## Provide large context When your goal is to reason with an LLM about implementing or fixing some code, you need to provide extensive information to the LLM: papers, big parts of the target code base (all the code base if possible, unless this is going to make the context window so large than the LLM performances will be impaired). And a brain dump of all your understanding of what should be done. Such braindump must contain especially the following: * Hints about bad solutions that may look good, and why they could be suboptimal. * Hints about very good potential solutions, even if not totally elaborated by the humans still: LLMs can often use them in order to find the right path. * Clear goals of what should be done, the invariants we require, and even the style the code should have. For instance, LLMs tend to write Python code that is full of unnecessary dependencies, but prompting may help reducing this problem. C code tends to be, in my experience, much better. When dealing with specific technologies that are not so widespread / obvious, it is often a good idea to also add the documentation in the context window. For example when writing tests for vector sets, a Redis data type so new that LLMs don’t yet know about, I add the README file in the context: with such trivial trick, the LLM can use vector sets at expert level immediately. ## Use the right LLMs The most famous LLMs are not the best. Coding activities should be performed mostly with: * Gemini 2.5 PRO * Claude Opus 4 Gemini 2.5 PRO is, in my experience, semantically more powerful. Can spot more complex bugs, reason about more complex problems. Claude Opus may be better at writing new code sometimes (sometimes not), the user interface is more pleasant, and in general you need at least two LLMs to do some back and forth for complex problems in order to enlarge your (human) understanding of the design space. If you can pick just one, go for Gemini 2.5 PRO. The fundamental requirement for the LLM to be used is: don’t use agents or things like editor with integrated coding agents. You want to: * Always show things to the most able model, the frontier LLM itself. * Avoid any RAG that will show only part of the code / context to the LLM. This destroys LLMs performance. You must be in control of what the LLM can see when providing a reply. * Always be part of the loop by moving code by hand from your terminal to the LLM web interface: this guarantees that you follow every process. You are still the coder, but augmented. ## Conclusions Despite the large interest in agents that can code alone, right now you can maximize your impact as a software developer by using LLMs in an explicit way, staying in the loop. This will inevitably change in the future, as AI will improve, and eventually many coding tasks will be better served by AI alone: in this future, the human will decide the what & how, which is still crucial. But we are not yet there. In this exact moment taking control allows to use LLMs to produce the sharpest code possible: minimal when needed, using complex ideas when required. You will be able to do things that are otherwise at the borders of your knowledge / expertise while learning much in the process (yes, you can learn from LLMs, as you can learn from books or colleagues: it is one of the forms of education possible, a new one). Yet, everything produced will follow your idea of code and product, and will be of high quality and will not random fail because of errors and shortcomings introduced by the LLM. You will also retain a strong understanding of all the code written and its design. From time to time, it is wise to test what agents can do. But each time you feel they can’t do as well as you can, return to your terminal, and code with the help of AI (when you feel it can improve your output; there are times where you just are better alone). When this will be true, that agents will perform superb work, I’ll be the first to switch, and I’ll keep coding by myself just for passion. But for now, let’s skip the hype, and use AI at its best, that is: retaining control. There is another risk, however: of avoiding LLMs for some ideological or psychological refusal, accumulating a disadvantages (and failing to develop a large set of skills - hard to describe - needed to work with LLMs). Maybe this is really a case of "In medio stat virtus". Comments
Alex Edwards
- How I use HTMX with GoJul 14, 2026
When I want to add sprinkles of interactivity to a web application, I'm a big fan of using HTMX. I like that it makes it easy to give interactions a smooth app-like feel, I like that it minimizes the amount of JavaScript that I have to write, and I like that it allows me to keep the consistency and safety of server-side HTML rendering with Go's html/template package. In this post I'm going to run through how I typically use HTMX in conjunction with Go. Although I'm going to talk a bit about how HTMX works, the main focus is going to be on the Go side of things. Specifically: Structuring and rendering HTML templates The patterns I use for sending back partial and full-page HTML responses to HTMX Managing redirects and errors when using HTMX The standard HTMX configuration settings that I use, and why To illustrate these things, we'll run through the build of a small application that ultimately implements a filter on a list of users like this: Note: If you're not already familiar with the basics of using HTMX, I recommend skimming through the HTMX docs before continuing. Also note: A lot of the patterns for working with HTML templates should also be a good fit for other HTML-over-the-wire tools like Unpoly and Hotwire too, if you prefer to use those. Project setup If you'd like to follow along, go ahead and run the following commands to create a skeleton structure for the project: $ go mod init example.com/htmx $ mkdir -p assets/static/css assets/static/img assets/static/js assets/html/partials assets/html/pages cmd/web $ touch assets/efs.go assets/html/base.tmpl assets/html/partials/images.tmpl assets/html/pages/home.tmpl cmd/web/main.go cmd/web/handlers.go cmd/web/html.go That should give you a file tree which looks like this: . ├── assets │ ├── efs.go │ ├── html │ │ ├── base.tmpl │ │ ├── pages │ │ │ └── home.tmpl │ │ └── partials │ │ └── images.tmpl │ └── static │ ├── css │ ├── img │ └── js ├── cmd │ └── web │ ├── handlers.go │ ├── html.go │ └── main.go └── go.mod Installing HTMX There are a few different ways to install HTMX, and you could load it from a CDN or install it using NPM, but I almost always download a copy and serve it as a static file from my web application. It's simple and avoids the downsides of using a CDN. For the purpose of this demo project, we'll also download Bamboo (a classless CSS framework) and an image of a gopher from github.com/egonelbre/gophers. Go ahead and run the following commands to download all three things into the assets/static folder: $ wget -P assets/static/js https://cdn.jsdelivr.net/npm/htmx.org@2.0.10/dist/htmx.min.js $ wget -P assets/static/css https://cdn.jsdelivr.net/npm/bamboo.css@1.4.0/dist/bamboo.min.css $ wget -O assets/static/img/gopher.png https://raw.githubusercontent.com/egonelbre/gophers/refs/heads/master/sketch/misc/standing-left.png The contents of assets/static should now look like this: assets/static ├── css │ └── bamboo.min.css ├── img │ └── gopher.png └── js └── htmx.min.js The HTML templates OK, now that the project skeleton and our static assets are in place, let's get to the main thrust of this post and talk about HTML templates. My starting point in almost all projects is an assets/html directory which has a folder structure like this: assets/html ├── base.tmpl ├── pages │ └── home.tmpl └── partials └── images.tmpl Under this structure: The assets/html/base.tmpl file contains the common HTML 'layout' markup for all web pages. The files in the assets/html/pages directory contain the page-specific content for individual web pages. The files in the assets/html/partials directory contain reusable chunks of HTML markup that can be used in different places. If you're following along, go ahead and add the following markup to the base.tmpl file: File: assets/html/base.tmpl {{define "base"}} {{template "page:title" .}} Example website {{template "page:content" .}} {{end}} There are a few things to point out about this: In the section we import the Bamboo CSS file and the HTMX JavaScript file. Note that when importing HTMX we use the defer attribute. This means that HTMX will be fetched by the browser in parallel as it is parsing the web page HTML, but the script won't be executed until the HTML is fully parsed and the DOM is built. There's an excellent blog post which describes how defer works and why it's the right choice here. When writing HTML templates, I like to give all of my templates explicit names by surrounding the markup in {{define}}...{{end}} actions — even if (like in this case) a file only contains one template and it's not strictly necessary. YMMV, but I prefer the consistency and clarity of being able to always refer to templates by defined names from my Go code, rather than using a mixture of defined names and filenames. Within the template, we use actions like {{template "page:title" .}} to inject the appropriate page-specific content in the right place. Talking of which, let's now add the page-specific content for the homepage to the assets/html/pages/home.tmpl file: File: assets/html/pages/home.tmpl {{define "page:title"}}Home{{end}} {{define "page:content"}} Wanna see a cute gopher? {{end}} In this page we have a with two HTMX attributes: hx-get="/gopher" and hx-swap="outerHTML". These mean that when this button is clicked, HTMX will intercept the click, send a GET /gopher request to our application, and then replace the button in the DOM with whatever HTML our application sends back. Note: The colon character in the template name like {{define "page:title"}} is just an arbitrary separator and you could name it something else, like page_title, page-title, pageTitle or even just title if you prefer. But I like using : because it feels like a natural and clear way to 'namespace' template names. Lastly, let's add a template to the assets/html/partials/images.tmpl containing some HTML for displaying our downloaded gopher image, like so: File: assets/html/partials/images.tmpl {{define "partial:image:gopher"}} {{end}} Note that we're using width="{{.}}" in this markup, so that we can pass a dynamic value for the image width to the template. Embedding the assets Since file embedding was introduced in Go 1.16, I normally embed HTML files and static assets into a Go binary rather than reading them from disk at runtime. Let's update the assets/efs.go file to embed the contents of the assets/html and assets/static directories, and make them available in two global variables called HTMLFiles and StaticFiles respectively. Like so: File: assets/efs.go package assets import ( "embed" "io/fs" ) //go:embed "html" "static" var files embed.FS var ( HTMLFiles = sub(files, "html") StaticFiles = sub(files, "static") ) func sub(f embed.FS, dir string) fs.FS { sub, err := fs.Sub(f, dir) if err != nil { panic(err) } return sub } In this code, the //go:embed "html" "static" directive embeds the contents of the assets/html and assets/static directories into the files variable, which is an embed.FS rooted in the assets directory. I've then used a small sub() function to create two sub-filesystems with their roots in the html and static directories, and assigned them to the HTMLFiles and StaticFiles variables respectively. Doing this has two benefits: It provides a clear separation between the static and HTML files when we are using them from our Go code. Code that is intended to only work with our static files won't have unnecessary access to our HTML files, and vice-versa. Code using the HTMLFiles and StaticFiles filesystems doesn't need to include the html/ or static/ path prefix when opening files. Note: If you don't want to call panic() from the sub() function, you could restructure this to return an error instead, and initialize the HTMLFiles and StaticFiles variables from within your main() function. But the risk of a runtime panic here is extremely low — the fs.Sub() function will only return an error if the dir value is not a valid path, and the static strings "html" and "static" always pass this check. In practice, I've never had any problems using this approach. HTML template rendering For rendering the HTML templates in an HTTP response, I've found that a nice pattern is to create a htmlRenderer type which a) parses a set of shared templates at startup; b) has a render() method that clones and extends the shared template set, before executing a specific named template and sending it as an HTTP response. Go ahead and create the htmlRenderer type in the cmd/web/html.go file like so: File: cmd/web/html.go package main import ( "bytes" "html/template" "io/fs" "net/http" "time" ) type htmlRenderer struct { templateFS fs.FS sharedTemplates *template.Template } // The newHTMLRenderer function creates a new htmlRenderer containing a shared // set of parsed templates with support for any custom template functions. func newHTMLRenderer(templateFS fs.FS, sharedTemplateFiles ...string) (*htmlRenderer, error) { funcs := template.FuncMap{ "now": time.Now, // Other custom template functions go here... } sharedTemplates, err := template.New("").Funcs(funcs).ParseFS(templateFS, sharedTemplateFiles...) if err != nil { return nil, err } r := &htmlRenderer{ templateFS: templateFS, sharedTemplates: sharedTemplates, } return r, nil } // The render method clones the shared template set, optionally parses additional // templates, executes the named template with the supplied data, and writes the // response. func (h *htmlRenderer) render(w http.ResponseWriter, status int, data any, templateName string, additionalTemplateFiles ...string) error { ts, err := h.sharedTemplates.Clone() if err != nil { return err } if len(additionalTemplateFiles) > 0 { ts, err = ts.ParseFS(h.templateFS, additionalTemplateFiles...) if err != nil { return err } } buf := new(bytes.Buffer) err = ts.ExecuteTemplate(buf, templateName, data) if err != nil { return err } w.WriteHeader(status) buf.WriteTo(w) return nil } And then in the cmd/web/main.go file, let's create a basic web application like so: File: cmd/web/main.go package main import ( "log/slog" "net/http" "os" "example.com/htmx/assets" ) // The application struct holds the dependencies needed for our handlers, // including a htmlRenderer type. type application struct { logger *slog.Logger html *htmlRenderer } func main() { logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) // Initialize a new htmlRenderer, parsing the base template and all partial // templates from assets/html into the shared template set. htmlRenderer, err := newHTMLRenderer(assets.HTMLFiles, "base.tmpl", "partials/*.tmpl") if err != nil { logger.Error(err.Error()) os.Exit(1) } // Include the htmlRenderer in the application struct. app := &application{ logger: logger, html: htmlRenderer, } // Create a file server that serves the files from assets/static. fileserver := http.FileServerFS(assets.StaticFiles) // Register the application routes. mux := http.NewServeMux() mux.Handle("GET /static/", http.StripPrefix("/static", fileserver)) mux.HandleFunc("GET /{$}", app.home) // Start the HTTP server. logger.Info("starting server", "port", 5051) err = http.ListenAndServe(":5051", mux) if err != nil { logger.Error(err.Error()) os.Exit(1) } } The important and relevant thing for this post is the initialization call to newHTMLRenderer(). In this call we pass in the glob paths "base.tmpl" and "partials/*.tmpl", which means that the base template and all templates in the partials directory will be available in the shared template set. And with that in place, we can then write the code for the home handler in cmd/web/handlers.go like so: File: cmd/web/handlers.go package main import ( "net/http" ) func (app *application) home(w http.ResponseWriter, r *http.Request) { err := app.html.render(w, 200, nil, "base", "pages/home.tmpl") if err != nil { app.logger.Error(err.Error()) http.Error(w, http.StatusText(500), 500) } } When we call render() in the code above, we are effectively saying append the templates in pages/home.tmpl to the shared template set, and then render the base template along with a 200 OK status. At this point, you should be able to successfully run the application: $ go run ./... time=2026-06-27T21:05:01.668+02:00 level=INFO msg="starting server" port=5051 And if you visit http://localhost:5051 in your browser, you should see the homepage displayed like so: Rendering partials While you're on this homepage, if you open developer tools and then click the "Wanna see a cute gopher?" button, you'll see that it sends a GET /gopher request that 404s. Let's fix this so that our application includes a GET /gopher route, which returns the contents of the partial:image:gopher template. First add the new route like so: File: cmd/web/main.go package main .. func main() { ... mux := http.NewServeMux() mux.Handle("GET /static/", http.StripPrefix("/static", fileserver)) mux.HandleFunc("GET /{$}", app.home) mux.HandleFunc("GET /gopher", app.gopher) ... } And then in cmd/web/handlers.go create a new gopher() handler, which renders the partial:image:gopher template with a width of 100px. File: cmd/web/handlers.go package main ... func (app *application) gopher(w http.ResponseWriter, r *http.Request) { width := 100 err := app.html.render(w, http.StatusOK, width, "partial:image:gopher") if err != nil { app.logger.Error(err.Error()) http.Error(w, http.StatusText(500), 500) } } Because we've set up our htmlRenderer type so that the shared template set already includes all partials, it's sufficient for us to call render() like this without passing in any additional file paths. If you re-run the application now and click the button, you should see that it gets swapped out for a gopher image like so: So, it's taken a while to get here, but the pattern that we now have in place is neat and has some nice benefits. Our templates (and static assets) are embedded into the Go binary, which makes for easy distribution and deployment. We can use the same htmlRenderer.render() function to send either complete HTML pages or specific partials to the client, which makes it easy to send back partial responses when they are needed by HTMX. We can keep the HTML markup nice and DRY by using the base template and partials. The partials can be inserted in the base template, page-specific content, or even in other partials. A more complex example That was very basic in terms of interactivity, so let's do something a bit more realistic and create a 'user search' page that mimics the active search example from the HTMX website. To make this work, we'll create two new routes in our application: A GET /users route which returns a full HTML page containing a table of all user details. A GET /users/search route which returns an HTML partial containing table rows only for users whose names or emails match a specific search value. Now that we've got all the groundwork in place, it should be pretty quick to do. Let's first add an assets/html/pages/users.tmpl file with the page-specific HTML content: $ touch assets/html/pages/users.tmpl File: assets/html/pages/users.tmpl {{define "page:title"}}Users{{end}} {{define "page:content"}} Name Email {{template "users:rows" .}} {{end}} {{define "users:rows"}} {{range .}} {{ .Name }} {{ .Email }} {{if .IsGopher}} {{template "partial:image:gopher" 24}} {{end}} {{end}} {{end}} There are a couple of interesting things here. The first is the HTMX attributes on the control. We've configured this so that when a user types into the input, after a delay of 500ms (or immediately if they press Enter), HTMX will send a request containing the search term as a query string like GET /users/search?query=foo. When a response is received, HTMX will then swap the response into the inner HTML of the element. For demonstration purposes in this project, we're also using the hx-push-url="true" attribute, which will result in the browser URL bar being updated and a new entry added to the browser history each time HTMX makes a request. I've also structured the file so that the table rows are rendered in their own users:rows template, rather than as part of the page:content template. We'll use this in the GET /users/search to render just the matching user table rows for HTMX to swap in. Note: In theory, we could define the users:rows template inside the partials directory instead, and that wouldn't be an unreasonable thing to do. But if I have a HTML fragment that is only used on one specific page, I think it's clearer and neater to define that fragment inside the page file alongside the other content for the page. YMMV though, and that's OK. Then let's set up the two new routes in main.go: File: cmd/web/main.go package main .. func main() { ... mux := http.NewServeMux() mux.Handle("GET /static/", http.StripPrefix("/static", fileserver)) mux.HandleFunc("GET /{$}", app.home) mux.HandleFunc("GET /gopher", app.gopher) mux.HandleFunc("GET /users", app.listUsers) mux.HandleFunc("GET /users/search", app.searchUsers) ... } And lastly let's go to the handlers.go file and create a hardcoded list of user details, along with the two new handlers listUsers and searchUsers, like so: File: cmd/web/handlers.go package main import ( "net/http" "strings" ) ... // Define a user type. The fields need to be exported so that we can reference // them in our HTML templates. type user struct { Name string Email string IsGopher bool } // Create a hardcoded list of users. var users = []user{ {"Alice Madsen", "alice.madsen@example.com", true}, {"Theo Thatcher", "theo.thatcher@example.com", true}, {"Maxwell Albright", "maxwell.albright@example.com", false}, {"Ruby Thompson", "ruby.thompson@example.com", false}, {"Leona Rowan", "leona.rowan@example.com", false}, {"Alicia Lennox", "alicia.lennox@example.com", true}, {"Ruben Mason", "ruben.mason@example.com", false}, {"Leo Reynolds", "leo.reynolds@example.com", false}, {"Max Lester", "max.lester@example.com", true}, {"Theodore Allister", "theodore.allister@example.com", false}, } func (app *application) listUsers(w http.ResponseWriter, r *http.Request) { // Render a full HTML page containing the content from "pages/users.tmpl" // and all user details. err := app.html.render(w, 200, users, "base", "pages/users.tmpl") if err != nil { app.logger.Error(err.Error()) http.Error(w, http.StatusText(500), 500) } } func (app *application) searchUsers(w http.ResponseWriter, r *http.Request) { // Filter down the list of users to find ones that match the query. query := r.FormValue("query") var matches []user if query == "" { matches = users } else { for _, u := range users { if strings.Contains(u.Name, query) || strings.Contains(u.Email, query) { matches = append(matches, u) } } } // Render just the "users:rows" template from the "pages/users.tmpl" file // with the matching user details. err := app.html.render(w, 200, matches, "users:rows", "pages/users.tmpl") if err != nil { app.logger.Error(err.Error()) http.Error(w, http.StatusText(500), 500) } } When it comes to template rendering, in both of these new handlers we are adding the templates from the pages/users.tmpl file to the shared template set, but in listUsers we execute the base template and in searchUsers we execute just the users:rows template. So with just a little bit of thought to how we structured the markup and defined the templates in the pages/users.tmpl file, it's straightforward for us to send back either a complete HTML document or the appropriate partial HTML fragment for HTMX to do its thing. If you want, try this out by visiting http://localhost:5051/users and you should see the list being filtered as you type. Note: I've been deliberately keeping this web application simple so that the focus is on templates and templating. In a real application, you might want to merge listUsers and searchUsers into a single handler, create some centralized helpers for error handling and logging, use middleware to add Content Security Policy headers and recover panics, set appropriate server timeouts, etc. Checking if a request is coming from HTMX This all works well, but what if someone visits a link like http://localhost:5051/users/search?query=leo directly? Or shares a link to it? Anyone visiting this directly would only see the partial HTML response in their browser, similar to this: This obviously isn't ideal. A much better approach would be to change the response that our searchUsers handler sends, depending on whether the request is coming from HTMX or not. Specifically: If the request is coming from HTMX, we should return an HTML partial that it can swap into the table, just like we already are. If the request is not coming from HTMX, we should return a full HTML page that contains the matching user details. As you may already know if you've used HTMX before, requests that come from HTMX always include an HX-Request: true header. So all we need to do is check for the presence of that in the request, and send back the appropriate response. To help with this, I normally create a little isHTMXRequest() function and use it like so: File: cmd/web/handlers.go package main ... func isHTMXRequest(r *http.Request) bool { return r.Header.Get("HX-Request") == "true" } func (app *application) searchUsers(w http.ResponseWriter, r *http.Request) { query := r.FormValue("query") var matches []user if query == "" { matches = users } else { for _, u := range users { if strings.Contains(u.Name, query) || strings.Contains(u.Email, query) { matches = append(matches, u) } } } // Render the base template by default. template := "base" // But if the request is coming from HTMX, render the users:rows template instead. if isHTMXRequest(r) { template = "users:rows" } err := app.html.render(w, 200, matches, template, "pages/users.tmpl") if err != nil { app.logger.Error(err.Error()) http.Error(w, http.StatusText(500), 500) } } If you restart the application and visit http://localhost:5051/users/search?query=leo again now, you should see a full HTML page containing only the matching user records. But there are a couple more things we need to do to finish this up. Because we're sending back different responses from searchUsers based on the value of the HX-Request header, we should also set a Vary: HX-Request on the response to tell any caches between our server and the client that responses may be different based on the value of this header. We could set the Vary: HX-Request header in searchUsers, but I think it's easier to just always set it on all responses in the render() function. It does mean that we'll be setting the Vary header on all responses — including those from our home handler and listUsers — which isn't strictly necessary and a little bit wasteful. But I think it's worth it to avoid having to remember setting the Vary header correctly in individual handlers, and the risk of bugs that forgetting it may cause. File: cmd/web/html.go func (h *htmlRenderer) render(w http.ResponseWriter, status int, data any, templateName string, additionalFiles ...string) error { ... w.Header().Add("Vary", "HX-Request") w.WriteHeader(status) buf.WriteTo(w) return nil } Lastly, we need to consider back-button behavior. Whenever HTMX adds an entry to the browser history (which it will do when you use the hx-push-url or hx-boost attributes), it caches the HTML for the complete page in the browser's local storage. When the user clicks the back button, this complete cached HTML page will be reshown to them. By default the HTMX cache stores up to 10 pages. If there is a cache miss (i.e. the user navigates back far enough that there is no longer a matching page in the cache), HTMX will resend the request to the server to refetch the content for that URL. The problem is that this request will include the HX-Request: true header, and our application will send back a partial HTML response rather than the complete HTML page that it needs to redisplay to the user. To deal with this scenario, there is an historyRestoreAsHxRequest setting which controls whether HTMX will include the HX-Request: true header when it's sending a request because of a cache miss. The documentation advises: This should always be disabled when using HX-Request header to optionally return partial responses. So let's go ahead and configure HTMX so that the historyRestoreAsHxRequest setting is false. There are a couple of ways you can configure HTMX settings, but I generally like to set them in a meta tag in the base.tmpl file like so: File: assets/html/base.tmpl {{define "base"}} {{template "page:title" .}} Example website {{template "page:content" .}} {{end}} Now that this is set, if there is a cache miss when using the back button, HTMX will send a request to our application without the HX-Request: true header, and our application will send back the complete HTML page to reshow to the user. Managing redirects Using HTMX in your application normally reduces the need for 3xx redirects. For example, when submitting a form you can often send back an HTML partial with a success message that can be swapped into the page, rather than using the standard Post/Redirect/Get pattern and redirecting to a confirmation page. But still, there may be times that you want to redirect to a completely new page after a form submission coming from HTMX. A common example would be redirecting to a profile page after a successful login. Unfortunately, to achieve this you can't just send a regular 3xx response. The crux of the problem is that that browsers will automatically intercept and follow 3xx responses before HTMX has access to them — so HTMX never gets to see the 3xx response, only the final response after any redirects. It doesn't know that a redirect happened behind the scenes, and will just swap in the returned content like normal. Instead, if you want something that behaves more like a regular redirect, you need to send a 2xx response along with the HX-Redirect header. For example, when you include the response header HX-Redirect: /foo/bar, it will make HTMX tell the browser to navigate to /foo/bar, triggering a full-page reload. Importantly the HX-Request: true header will not be included in the request to /foo/bar. But you also need to handle the situation where the original request might not be coming from HTMX — especially if you are using progressive enhancement so that your application still works if JavaScript is disabled or HTMX doesn't load correctly. In that case, it's important to fallback to sending a regular 3xx response from your Go handler, rather than the 2xx response and HX-Redirect header. Putting this together, I normally create a redirect() helper which leverages the isHTMXRequest() function we made earlier and looks like this: func redirect(w http.ResponseWriter, r *http.Request, url string, code int) { if isHTMXRequest(r) { w.Header().Set("HX-Redirect", url) w.WriteHeader(http.StatusNoContent) return } http.Redirect(w, r, url, code) } And, for example, in the scenario of wanting to redirect to a /profile page after a successful login, I use it in my Go handlers like this: redirect(w, r, "/profile", http.StatusSeeOther) As I mentioned above, using HX-Redirect will trigger a full-page reload. But there is another option — the HX-Location header — which makes HTMX mimic the behavior of a redirect without a full-page reload. It essentially makes HTMX fetch the HTML for the provided URL, swap it into the HTML body, and add a new entry to the browser history. Importantly, when fetching the HTML the HX-Request: true header is used. At first glance, using HX-Location might seem preferable because it doesn't make a full-page reload, which gives your application a smoother more SPA-like experience. But it's a problem if the route that you are redirecting to uses the HX-Request: true header to conditionally send HTML partials. The handler has no way of telling whether the request is coming from HTMX following a HX-Location redirect (in which case it should send a full-page response) or from a 'normal' HTMX request (in which case it should return a partial). Unfortunately, unlike history restore requests, HTMX doesn't provide a setting to disable the HX-Request: true header when redirecting. So, most of the time I think it's easier and safer to use HX-Redirect and accept the downside of a full-page reload. But... if you are careful and structure your application so that the routes you redirect to only ever return full HTML pages, you may want to change the redirect() helper to use HX-Location instead like so: func redirect(w http.ResponseWriter, r *http.Request, url string, code int) { if isHTMXRequest(r) { w.Header().Set("HX-Location", url) w.WriteHeader(http.StatusNoContent) return } http.Redirect(w, r, url, code) } Managing errors If our demo application returns a 4xx or 5xx response, by default HTMX will not swap in the response. Instead it leaves the DOM as-is, and logs an error message in the console. If you'd like to see this in action, go ahead and change the gopher handler to render a "partial:image:missing" template (which doesn't exist). This should cause our application to error and send a 500 status code and a plaintext "Internal Server Error" response to the client. File: cmd/web/handlers.go func (app *application) gopher(w http.ResponseWriter, r *http.Request) { err := app.html.render(w, http.StatusOK, 100, "partial:image:missing") if err != nil { app.logger.Error(err.Error()) http.Error(w, http.StatusText(500), 500) } } If you run the application and click the "Wanna see a cute gopher?" button, now nothing on the screen will change, but in your developer tools network tab you'll see the 500 response and a record of the problem in the console, like so: In most cases, this isn't ideal. If an application is sending back an error message, I normally want the user to actually see this message rather than having the operation fail silently for them (or at least, silently unless they have developer tools open 😉). And also in most cases, I want to display any error message from a 4xx or 5xx response as full-page HTML (in the same way that it would be shown if we weren't using HTMX) by swapping it into the element rather than swapping it into the regular HTMX target. The only exception to this is the 422 Unprocessable Content status, which I typically use when sending back a form with validation errors in it. In this case, I want HTMX to swap the returned content into the target element as normal. Luckily, you can use the HTMX responseHandling setting to configure different behavior for different responses codes. I normally configure this so that: For 204 No Content responses, no action is taken and no changes are made to the DOM. For 422 Unprocessable Content responses, HTMX swaps the response content into the target as normal. For all other 4xx and 5xx responses, HTMX swaps the returned content into the element. For any other response, HTMX swaps the response content into the target as normal. And just like before, I normally configure this via the HTMX configuration meta tag like so: File: assets/html/base.tmpl {{define "base"}} {{template "page:title" .}} Example website {{template "page:content" .}} {{end}} With that change made, if you restart the application and click the button again, you should now see the "Internal Server Error" message shown as a full-page response, like so: Note: If I ever want to swap an error message into a target that isn't the element, then I use the response target extension to override the above settings for a specific interaction and swap into a specific target. But... I find that the above settings are a good starting default. Current browser URL Because HTMX makes AJAX requests and swaps in responses without changing the browser URL (unless you use the hx-boost, hx-push-url or hx-replace-url attributes), the request URL that we see in our Go handlers when accessing r.URL may be different to the one that the user is seeing in their browser. Occasionally, there are times when I want to know in my Go handler exactly what URL the user is currently seeing in their browser. Fortunately, HTMX sends this information with each request in the HX-Current-URL header. Usually I make another little function to help with this, which parses the HX-Current-URL value and returns it as a url.URL, falling back to returning r.URL if no HX-Current-URL header is present. Like so: func browserURL(r *http.Request) (*url.URL, error) { cu := r.Header.Get("HX-Current-URL") if cu != "" { return url.Parse(cu) } return r.URL, nil } Additional HTMX configuration Lastly, there are a few other HTMX configuration settings that I normally change from the default values. I tend to disable the HTMX cache completely by setting historyCacheSize to 0. Caching pages in local storage is a source of bugs and security issues, so I think it's simpler and better to just disable it completely. If you do this, no pages will be cached in local storage, and when the user clicks the back button HTMX will send a request to the server to refetch the HTML. It's worth noting that caching in local storage will also be disabled by default in future versions of HTMX for the same reasons. I prefer to disable HTMX attribute inheritance by setting disableInheritance to true. I think it's clearer and lowers the risk of bugs or unintended behavior when HTMX attributes are always declared explicitly. Again, it's worth noting that attribute inheritance will also be disabled by default in future versions of HTMX. I also disable HTMX indicator styles by setting includeIndicatorStyles to false. For consistency, I prefer not to have HTMX injecting styles, and would rather define any indicator styles alongside my other CSS rules. By default there is no timeout on HTMX requests, and they will wait as long as necessary for the server to respond. It's project-specific, and depends on how I'm handling timeouts and deadlines in my Go application, but sometimes I may also use the timeout setting to set a default timeout (in milliseconds) on the HTMX end. All in all, the starting point for my HTMX configuration settings normally looks like this: Page-specific layouts Let's finish up this post with a final note about HTML templates in larger applications. For some applications, having a base template along with page-specific templates might not be enough. You might also want 'layout' templates that sit between the base template and your page-specific content — for example, you might want to use a layout template for your admin-area pages that is different to the rest of your regular application pages. The patterns that we've talked about in this post can be extended fairly easily to accommodate this. For example, you can change the base template to insert a "layout" template in the body element instead of the page-specific content directly: {{define "base"}} {{template "page:title" .}} ... {{template "layout" .}} {{end}} Then you could create an assets/html/layouts/admin.tmpl file containing the common 'layout' markup for the admin pages: {{define "layout"}} Admin area Users Orders {{template "page:content" .}} {{end}} And then you can specify which layout template you want to use in your Go handlers as part of the call to render(). Like so: func (app *application) adminOrders(w http.ResponseWriter, r *http.Request) { err := app.html.render(w, 200, nil, "base", "layouts/admin.tmpl", "pages/admin-orders.tmpl") if err != nil { app.logger.Error(err.Error()) http.Error(w, http.StatusText(500), 500) } }
- Go Naming Conventions: A Practical GuideMar 24, 2026
Choosing the right names in your codebase is an important (and sometimes difficult!) part of programming in Go. It's a small thing that makes a big difference — good names make your code clearer, more predictable, and easier to navigate; bad names do the opposite. Go has fairly strong conventions — and a few hard rules — for naming things. In this post we're going to explain these rules and conventions, provide some practical tips, and demonstrate some examples of good and bad names in Go. If you're new to the language, all this information might feel like a lot to take in, but it'll quickly become second nature with a bit of practice 😊 Identifiers Let's start with the hard rules for identifiers. By identifiers, I mean the names that you use for the variables, constants, types, functions, parameters, struct fields, methods and receivers in your code. Identifiers can contain unicode letters, digits, and underscores only. Identifiers cannot begin with a digit. You cannot use any of the following Go keywords as identifiers: break default func interface select case defer go map struct chan else goto package switch const fallthrough if range type continue for import return var So long as you stick to those three rules, any identifier name is technically valid and your code will compile all OK. But there are a bunch of other guidelines that it's good practice to follow: You should use camelCase for unexported identifiers, or PascalCase for exported identifiers. Don't use alternative casing variants like snake_case, Pascal_Snake_Case, SCREAMING_SNAKE_CASE or ALLUPPERCASE. Words that are acronyms or initialisms (like API, URL or HTTP) should use a consistent case within the identifier. So, for example, apiKey or APIKey is good, but ApiKey is not. This rule also applies to ID when it is used as shorthand for the words "identity" or "identifier" — so that means write userID rather than userId. Although all unicode letters are allowed, using non-ASCII letters can often make your code harder to read and more awkward to write, and it's rare to see them used. Unless you have a really appropriate use-case, you should stick to using ASCII letters in identifiers. For example, use pi instead of π, use beta instead of β, use naiveBayes instead of naïveBayes. To prevent confusion for readers and potential bugs, avoid choosing identifiers that clash with Go's builtin types. So, for example, don't create variables with names like int, bool or any. Similarly, avoid creating functions with names that clash with Go's builtin functions. So, for example, don't create functions with names like min, max, len or clear. Generally, avoid including the type in identifiers — for example, don't use names like fullNameString, scoreInt or float64Amount. The main exception to this is when you have to convert a variable to a different type, and you want to distinguish between the original variable and the one containing the converted value. In this situation, including the type in the identifier is a common and acceptable way to distinguish between the two. For example, code like this is OK: userID := 42 userIDStr := strconv.Itoa(userID) Where possible, try to avoid choosing identifiers that clash with the standard library package names. This is a 'softer' convention than the others because the standard library steals a lot of good identifier names — such as json, js, mail, user, csv, path, filepath, log, regexp, time and url — and sometimes it can be hard to come up with decent alternatives. However, you definitely should avoid creating identifiers that clash with the package names that your code is actually importing and using. So, for example, if you are writing code that imports the url and net/mail packages, then don't use the words url and mail as identifiers in that code. Here are a few examples of good and bad identifier names: Bad Reason Better order.total := 99.99func load-user() Punctuation not allowed orderTotal := 99.99func loadUser() const 3rdParty = "x"func 2FactorAuth() Cannot start with a digit const thirdParty = "x"func twoFactorAuth() max_value := 10func Fetch_user() Non-standard casing maxValue := 10func FetchUser() type HttpClient struct{}func parseXml() Inconsistent acronym casing type HTTPClient struct{}func parseXML() func GetSessionId()type OrderId string ID should be all caps func GetSessionID()type OrderID string résuméCount := 2const Σ = 100 Non-ASCII letters resumeCount := 2const sum = 100 func clear()int := cache.Internal() Clashes with builtin types or functions func clearQueue()data := cache.Internal() intCount := 42resultSlice := []int{} Type included in name count := 42results := []int{} type json struct{}var log = newLogger() Clashes with stdlib package names type payload struct{}var logger = newLogger() Exported and unexported identifiers Identifiers in Go are case-sensitive. For example, the identifiers apiKey, apikey and APIKey are all different. As you probably already know, when an identifier starts with a capital letter it is exported — that is, it's visible to code outside of the package it's declared in. This means that the casing of the first letter is significant. It impacts the behavior of your codebase. In turn, this means that you shouldn't start identifiers with a capital letter just because they look nice — you should only start them with a capital letter if you want them to be exported and accessible to code outside the package they are declared in. As a tip, try to write packages using unexported identifiers by default. Only export them when you actually have a need to. Typically, the less you export, the easier it is to refactor code within a package without affecting other parts of your codebase. There's a nice quote from The Pragmatic Programmer, which I'll adapt slightly for the Go nomenclature: Write shy code - packages that don't reveal anything unnecessary to other packages and don't rely on other packages' implementations. As a second tip, it's very rare for a main package to be imported by anything, so the identifiers in it should normally all be unexported and start with a lowercase letter. The most frequent exception to this is when you need to export a struct field so that it's visible to packages that use reflection to work, like encoding/json, encoding/gob or github.com/jmoiron/sqlx. Identifier length and descriptiveness In general, the further away that an identifier is used from where it is declared, the more descriptive the name should be. If you have an identifier which is narrow in scope and only used close to where it is declared, it's generally OK to use a short and not-very-descriptive name. For example, if you're naming something that is only used in a small for loop, range block, or very short function, then using short or even single letter names is very common in Go. But it you're naming something that has a larger scope, or is used far away from where it is declared, you should use a name that clearly describes what the thing represents. Here is a nice example that Dave Cheney gave as part of his Practical Go presentation: type Person struct { Name string Age int } func AverageAge(people []Person) int { if len(people) == 0 { return 0 } var count, sum int for _, p := range people { sum += p.Age count += 1 } return sum / count } In this code, within the short range block we use the identifier p to represent a value in the people slice — the range block is so small and tight that using a single letter name is clear enough. In contrast, the count and sum variables are declared, then used inside the range, then again in the return statement. Giving them more descriptive names makes it immediately clearer what the code is doing and what they represent, compared to single letter names like c and s. But these variables are only ever used inside the AverageAge function, so giving them even-more-descriptive names like peopleCount and agesSum would be unnecessarily verbose. It's not an exact science, but when writing Go code you are encouraged to use the right length identifier — sometimes that might be long and descriptive, sometimes it might be short and terse. Naming packages The hard rules for package names are the same as for identifiers: they can contain unicode letters, numbers and underscores, must not begin with a number, and must not be a Go keyword. But in practice, the conventions for naming a package are much tighter. Conventionally: Package names should contain lower case ASCII letters and numbers only. Because package names will need to be typed out a lot when writing code, the name should ideally be short, easy to type, and reflect the contents of the package. Often simple one-word nouns (like orders, customer and slug) work well. If you want to use more than one word in the package name, you should concatenate the words all in lowercase with no separator. So, for example, ordermanager is a good package name — orderManager or order_manager are not. If a package name feels too long, it can be OK to use abbreviations in the name. You can see this in some of the standard library package names, like expvar (instead of exportedvariables) and strconv (instead of stringconversion). To prevent conflicts and confusion, try to avoid using the same name as commonly-used standard library packages. Package names with the prefix . or _ are 'invisible' to Go and completely ignored when you run go build, go run, go test etc. So don't start your package name with these characters, unless you specifically want them to be ignored. Directories with the names vendor, testdata and internal have a special meaning in Go, so to avoid any confusion or bugs, don't use these words as package names. Avoid using 'catch all' package names like common, util, helpers, types or interfaces, which don't really give any clue to what the package contains. For example, does a package called helpers contain validation helpers, formatting helpers, SQL helpers? A mix of all the above? You can't guess from just the name alone. As well as not being clear, these kind of 'catch all' names provide little natural boundary or scope, which can lead to the package becoming a dumping ground for lots of different things. In turn the package may become imported and used throughout your codebase — which increases the risk of import cycles and means that changes to the package potentially affect the whole codebase, rather than just a specific part of it. In other words, catch all package names encourage creating packages which have a large 'blast radius'. If you find yourself wanting to create a utils or helpers package, ask yourself if you can break up the contents into smaller packages with a specific focus and clearer names instead. Bad Reason Better package 3rdpartypackage 2fa Cannot start with a digit package thirdpartypackage twofa package OrderManagerpackage order_manager Non-standard casing / separators package ordermanager package opackage stuff Too vague and not descriptive package orderspackage slug package ordermanagementsystem Too long / hard to type package orderspackage ordermgr package urlpackage mail Clashes with stdlib package names package linkspackage mailer package _cachepackage .hidden Ignored by Go tooling package cachepackage hidden package internalpackage vendorpackage testdata Special directory names in Go package internalauthpackage supplier package utilspackage helpers Catch-all names with unclear scope package validationpackage formatting Naming files In an ideal world, a .go filename should summarize what the file contains, be one word long, and all in lowercase. Some examples of good filenames from the standard library net/http package are cookie.go, server.go and status.go. If you can't think of a good one-word name, and want to use two or more words, there is no clear convention for how those words should be separated. Even in the Go standard library itself there isn't consistency. Sometimes underscores are used to separate the words in filenames (like routing_index.go and routing_tree.go), and other times they are concatenated with nothing between them (like batchcursor.go, textreader.go and reverseproxy.go). Because there isn't a strong convention around this, I recommend just picking one of these two approaches and sticking to it consistently within a codebase. Personally, I think it's better to concatenate words with nothing between them (like routingindex.go), and reserve the underscore character for only when you want to use a special filename suffix. Talking of which, there are some filename prefixes and suffixes that have a special meaning in Go. You should avoid using these in your filenames unless you want to trigger the special behavior. Specifically: Like packages, filenames with the prefix . or _ are 'invisible' to the Go tooling and completely ignored when you run go build, go run, go test etc. Files with the suffix _test.go are only run by the go test tool. They are ignored when using go run or go build. Files with any of the following suffixes will only be included when compiling for that specific operating system: _aix.go, _android.go, _darwin.go, _dragonfly.go, _freebsd.go, _illumos.go, _ios.go, _js.go, _linux.go, _netbsd.go, _openbsd.go, _plan9.go, _solaris.go, _wasip1.go, _windows.go. Similarly, files with any of the following suffixes will only be included when compiling for that specific architecture: _386.go, _amd64.go, _arm.go, _arm64.go, _loong64.go, _mips.go, _mips64.go, _mips64le.go, _mipsle.go, _ppc64.go, _ppc64le.go, _riscv64.go, _s390x.go, _wasm.go. Avoiding chatter When you are naming exported functions, try to avoid repeating the name of the package they are declared in. For example, if you have a package called customer, then function names like NewCustomer() or CustomerOrders() would be 'chattery' and unnecessarily repeat the word 'customer' when you call them from outside the package — like customer.NewCustomer() and customer.CustomerOrders(). Calling the functions New() and Orders() is sufficient and reads better at the call site — like customer.New() and customer.Orders(). The same advice also applies to exported types. For example, if you want to represent an address or phone number in a customer package, it's sufficient and less chattery to name the types Address and PhoneNumber rather than CustomerAddress and CustomerPhoneNumber. Bad Reason Better customer.NewCustomer()customer.CustomerOrders() Chattery function call customer.New()customer.Orders() customer.CustomerAddresscustomer.CustomerPhoneNumber Chattery type reference customer.Addresscustomer.PhoneNumber Note: It's common to want to declare an exported type that shares the same name as the package. For example, a customer package might export a Customer type that represents an individual customer. We would then reference this type in other packages by writing customers.Customer. This is obviously chattery, but it's hard to avoid this repetition without giving either the package or type a name that makes it less clear. So in practice, this is something that you'll see a lot. For example, in the standard library the time package has a Time type, which you reference by typing time.Time, the context package contains a Context type which you reference by typing context.Context, and the regexp package contains a Regexp type which you reference by typing regexp.Regexp. Similar to function and type names, method names should ideally not 'chatter' too much when calling them. For example, if you are writing methods on a Token type, for example, it's probably OK to call a method Validate() rather than ValidateToken(), or IsExpired() rather than IsTokenExpired(). Method receivers When you are creating methods, it is conventional for the method receiver to have a short name, normally between 1 and 3 characters long and often an abbreviation of the type that the method is implemented on. For example, if you are implementing a method on a Customer type, an idiomatic receiver name would be something like c or cus. Or if you were implementing a method on a HighScore type, a good receiver name would be hs. The Go code review comments advise against using generic names like this, self or me for the receiver. Also, you should be consistent with the receiver name. All methods on the same type should use the same receiver name — don't use c for one method and cus for another. type Order struct { Items int } // Good: uses a short receiver func (o *Order) Validate() bool { return o.Items > 0 } // Bad: uses a longer receiver name func (order *Order) Validate() bool { return order.Items > 0 } // Bad: uses a generic receiver name func (self *Order) Validate() bool { return self.Items > 0 } Getter and setter methods on structs Typically, it is not necessary to create 'getter' and 'setter' methods on struct types in Go. Instead, you just access the struct field directly to read or change the data. The major exception to this is when you have a struct with an unexported field, but want to provide a way to get or set the field value from outside the package. To do this, you need to create exported 'getter' and 'setter' methods that read and write to the unexported field. When doing this, it is conventional to prefix the setter method name with Set, but not prefix the getter method name with Get. Like so: type Customer struct { address string } func (c *Customer) Address() string { return c.address } func (c *Customer) SetAddress(addr string) { c.address = addr } Interfaces By convention, interfaces that only contain one method should be named by the method name plus an '-er' suffix or similar. For example: type Speaker interface { Speak() string } type Authorizer interface { Authorize(ctx context.Context, action string) error } type Authenticator interface { Authenticate(ctx context.Context) (User, error) } The Go standard library has quite a few examples of interfaces that follow this convention, such as io.Reader, io.Writer and fmt.Stringer. Also note that the guidance to avoid including the type in the name still applies to interfaces. Don't give your interfaces names like UserInterface or OrderInterface unless you really can't think of a decent alternative. Breaking from conventions There are rare occasions when breaking a convention can actually make your code clearer — and in my view, it can be OK to do that... especially if it's in a private codebase worked on by a small team. For example, a couple of years ago I was working on a Go program that synchronizes data between some other external systems. In this project, I ended up breaking some of the Go naming conventions around casing and separators — instead using exactly the same identifiers that the external systems used. This actually made the intent of the program clearer, and more immediately obvious what was being synchronized with what. But the vast majority of the time, you should endeavour to follow the naming rules and conventions we've discussed in this post. They exist for good reasons: they make your code more predictable and consistent, easier for other Gophers to quickly understand, and reduce the risk of certain bugs.
- A modern approach to preventing CSRF in GoOct 14, 2025
Go 1.25 introduced a new http.CrossOriginProtection middleware to the standard library — and it got me wondering: Have we finally reached the point where CSRF attacks can be prevented without relying on a token-based check (like double-submit cookies)? Can we build secure web applications without bringing in third-party packages like justinas/nosurf or gorilla/csrf? And I think the answer now may be a cautious “yes” — so long as a few important conditions are met. If you want to skip the explanations and just want to see what those conditions are, you can click here. The http.CrossOriginProtection middleware The new http.CrossOriginProtection middleware works by checking the values in a request's Sec-Fetch-Site and Origin headers to determine where the request is coming from. It will automatically reject any non-safe requests that are not from the same origin, and will send the client a 403 Forbidden response. The http.CrossOriginProtection middleware has some limitations, which we'll discuss in a moment, but it is robust and simple to use, and a great addition to the standard library. How it works Modern browsers automatically include the Sec-Fetch-Site header in requests. This header indicates the relationship between the origin of the page making the request, and the origin of the page being requested. Two pages are considered to have the same origin if their scheme, hostname and port (if present) exactly match, in which case the browser will include a Sec-Fetch-Site: same-origin header in the request. If the two pages don't have the same origin, the Sec-Fetch-Site header will be set to a different value to indicate this, and http.CrossOriginProtection will reject the request. If no Sec-Fetch-Site header is present, http.CrossOriginProtection will fall back to checking the Origin header. Specifically, it will compare the request's Origin header and Host header to see if they match. If they don't match, then it considers the request to not be from the same origin and it will reject it. If neither the Sec-Fetch-Site nor Origin headers are present, then it assumes the request is not coming from web browser and will always allow the request to proceed. The checks described above only take place on requests with non-safe methods (POST, PUT, etc.). Requests with safe HTTP methods (GET, OPTIONS, etc.) are always allowed to proceed. If you're interested in learning more about the design and decision making behind http.CrossOriginProtection, the original proposal by Filippo Valsorda is an excellent read. At its simplest, you can use it like this: File: main.go package main import ( "fmt" "log/slog" "net/http" "os" ) func main() { mux := http.NewServeMux() mux.HandleFunc("/", home) slog.Info("starting server on :4000") // Wrap the mux with the http.NewCrossOriginProtection middleware. err := http.ListenAndServe(":4000", http.NewCrossOriginProtection().Handler(mux)) if err != nil { slog.Error(err.Error()) os.Exit(1) } } func home(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "Hello!") } If you want, it's also possible to configure the behavior of http.CrossOriginProtection. Configuration options include being able to add trusted origins (from which cross-origin requests are allowed), and the ability to use a custom handler for rejected requests instead of the default 403 Forbidden response. When I've wanted to customize the behavior, I've been using a pattern like this: File: main.go package main import ( "fmt" "log/slog" "net/http" "os" ) func main() { mux := http.NewServeMux() mux.HandleFunc("/", home) slog.Info("starting server on :4000") err := http.ListenAndServe(":4000", preventCSRF(mux)) if err != nil { slog.Error(err.Error()) os.Exit(1) } } func preventCSRF(next http.Handler) http.Handler { cop := http.NewCrossOriginProtection() cop.AddTrustedOrigin("https://foo.example.com") cop.SetDenyHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) w.Write([]byte("CSRF check failed")) })) return cop.Handler(next) } func home(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "Hello!") } Limitations The big limitation of http.CrossOriginProtection is that it is only effective at blocking requests from modern browsers. Your application will still be vulnerable to CSRF attacks coming from older (generally pre-2020) browsers which do not include at least one of the Sec-Fetch-Site or Origin headers in requests. Right now, browser support for the Sec-Fetch-Site header is at 92%, and for Origin it is 95%. So — in general — relying on http.CrossOriginProtection is not sufficient as your only protection against CSRF. It's also important to note that the Sec-Fetch-Site header is only sent when your application has a "trustworthy origin" — which basically means that your application needs to be using HTTPS in production (or localhost in development) for http.CrossOriginProtection to work to its full potential. And you should also be aware that when no Sec-Fetch-Site header is present in a request, and it falls back to comparing the Origin and Host headers, the Host header does not include the scheme. This limitation means that http.CrossOriginProtection will wrongly allow cross-origin requests from http://{host} to https://{host} when there is no Sec-Fetch-Site header present but there is an Origin header. To mitigate this risk, you should ideally configure your application to use HTTP Strict Transport Security (HSTS). Enforcing TLS 1.3 Looking into this got me wondering... What if you're already planning to use HTTPS and enforce TLS 1.3 as the minimum supported TLS version? Could you be confident that all web browsers which support TLS 1.3 also support either the Sec-Fetch-Site or Origin headers? As far as I can tell from the MDN compatibility data and tables from Can I Use, the answer is "yes" for (almost) all major browsers. If you enforce TLS 1.3 as the minimum version: Older browsers which don't support TLS 1.3 simply won't be able to connect to your application. For the modern major browsers that do support TLS 1.3 and can connect, you can be confident that at least one of the Sec-Fetch-Site or Origin headers are supported — and therefore http.CrossOriginProtection will work effectively. The only exception to this I can see is Firefox v60-69 (2018-2019), which did not support the Sec-Fetch-Site header and did not send the Origin header for POST requests. This means that http.CrossOriginProtection will not work effectively to block requests originating from that browser. Can I Use puts usage of Firefox v60-69 at 0%, so the risk here appears very low — but there are probably some computers somewhere in the world still running it. Also, we only have this information for the major browsers — Chrome/Chromium, Firefox, Edge, Safari, Opera and Internet Explorer. But of course, other browsers exist. Most of them are forks of Chromium or Firefox and therefore will likely be OK, but there's no guarantee here and it is hard to quantify the risk. So if you use HTTPS and enforce TLS 1.3, it's a huge step forward in making sure that http.CrossOriginProtection can work effectively. However, there remains a non-zero risk that comes from Firefox v60-69 and non-major browsers, so you may want to add some defense-in-depth and utilize SameSite cookies too. We'll talk more about SameSite cookies in a moment, but first we need to take a quick detour and discuss the difference between the terms origin and site. Cross-site vs cross-origin In the world of web specifications and web browsers, cross-site and cross-origin are subtly different things, and in a security context like this it's important to understand the difference and be exact about what we mean. I'll quickly explain. Two websites have the same origin if they share the exact same scheme, hostname, and port (if present). So https://example.com and https://www.example.com are not the same origin because the hostnames (example.com and www.example.com) are different. A request between them would be cross-origin. Two websites are 'same site' if they share the same scheme and registerable domain. Note: The registerable domain is the part of the hostname just before (and including) the effective TLD. Here are a few examples: For https://www.google.com/ the TLD is com and the registerable domain is google.com. For https://login.mail.ucla.edu the TLD is edu and the registerable domain is ucla.edu. For https://www.gov.uk, the TLD is gov.uk and the registerable domain is www.gov.uk. You can find the complete list of effective TLDs here. So https://example.com, https://www.example.com and https://login.admin.example.com are all considered to be the same site because the scheme (https) and registerable domain (example.com) are the same. A request between these would not be considered to be cross-site, but it would be cross-origin. Note: Some browser versions use a different definition of same-site which doesn't require the same scheme, only the same registrable domain. For these browser versions, https://admin.example.com and http://blog.example.com would also be considered same-site. Nowadays, this is typically referred to as schemaless same-site, but in historical versions or documentation it may have just been called same-site. So what are the points that I'm building up to here? Go's http.CrossOriginProtection middleware is accurately and appropriately named. It blocks cross-origin requests. It's more strict than it would be if it only blocked cross-site requests, because it also blocks requests from other origins under the same site (i.e. registrable domain). This is useful because it helps to prevent a situation where your janky-not-been-updated-in-the-last-decade WordPress blog at https://blog.example.com is compromised and used to launch a request forgery attack at your important https://admin.example.com website. When most people — myself included — casually talk about "CSRF attacks", what we are referring to most of the time is actually cross-origin request forgery, not just cross-site request forgery. It's a shame that CSRF is the commonly used and known acronym to describe this family of attacks, because most of the time CORF would be more accurate and appropriate. But hey! That's the messy world we live in. For the rest of this post though, I'll use the term CORF instead of CSRF when that is exactly what I mean. SameSite cookies The SameSite cookie attribute has generally been supported by web browsers since 2017, and by Go since v1.11. If you set the SameSite=Lax or SameSite=Strict attributes on a cookie, that cookie will only be included in requests to the same site that set it. In turn, that prevents cross-site request forgery attacks (but not cross-origin attacks from within the same site). There is some good news here — all major browsers that support TLS 1.3 also fully support SameSite cookies, with no exceptions that I can see. So if you enforce TLS 1.3, you can be confident that all the major browsers using your application will respect the SameSite attribute. This means that by using SameSite=Lax or SameSite=Strict on your cookies, you cover off the risk of cross-site request forgeries from Firefox v60-69 that we talked about earlier. Putting it all together If you combine using HTTPS, enforcing TLS 1.3 as the minimum version, using SameSite=Lax or SameSite=Strict cookies appropriately, and using the http.CrossOriginProtection middleware in your application, as far as I can see there are only two unmitigated CSRF/CORF risks from major browsers: CORF attacks from within the same site (i.e. from another subdomain under your registrable domain) in Firefox v60-69. CORF attacks from a HTTP version of your origin, from browsers that do not support the Sec-Fetch-Site header. For the first of these risks, if you don't have any other websites under your registrable domain, or you're confident that the websites are secure and uncompromised, then this might be a risk that you're willing to accept given the extremely low usage of Firefox v60-69. For the second, if you don't support HTTP on your origin at all (including redirects) then this isn't something you need to worry about. Otherwise, you can mitigate the risk by including a HSTS header on your HTTPS responses. At the start of this article, I said that not using a token-based CSRF check might be OK under certain conditions. So let's run through what those are: Your application uses HTTPS and enforces TLS 1.3 as the minimum version. You accept that users with older browsers will not be able to connect to your application at all. You follow good-practice and never change important application state in response to requests with the safe methods GET, HEAD, OPTIONS or TRACE. You use both the http.CrossOriginProtection middleware and SameSite=Lax or SameSite=Strict cookies. It's important to still use SameSite cookies for general defense in depth, but more specifically to mitigate CSRF attacks from Firefox v60-69. Because of the unprotected risk of a same-site CORF attack from Firefox v60-69, you either don't have any other websites under your registrable domain, or you're confident that they're secure and uncompromised. There is either no HTTP version of your application origin at all, or you include a HSTS header on your HTTPS responses. Finally, you are willing to accept the difficult-to-quantify risk of CSRF/CORF attacks from non-major browsers that support TLS 1.3 but don't support the Origin header, Sec-Fetch-Site header or SameSite cookies. Does any such browser exist? I don't know, and I'm not sure there's a way to answer that question with 100% confidence. So you'll need to do your own risk assessment here, and it's a risk that you probably only want to accept if your application is a low-value target and the impact of a successful CSRF/CORF attack is both isolated and minor.
- The 9 Go test assertions I use (and why)Aug 22, 2025
A few weeks ago Anton Zhiyanov published the blog post Expressive tests without testify/assert. It's a good and well thought-out post, and I recommend giving it a read if you haven't already. In the post, Anton makes the argument for not using packages like testify/assert for your test assertions, and instead creating your own minimal set of assertion helpers to use in your tests. In fact, so minimal that there are only 3 helpers he uses: AssertEqual, AssertErr and AssertTrue. There are some people who would argue that even this is too much, and that you shouldn't use assertion functions in your tests at all. In fact, the Go Code Review Comments for Tests states that using assert packages should be avoided, which we'll talk about in more detail at the end of this post. But I agree with the general direction of Anton's thinking. I do use assertion functions — and I've always preferred to write my own rather than using a third-party package. Over time I've whittled them down to a standard collection of nine basic functions that I use: Assertion What it checks Equal(got, want) Checks that got and want are equal NotEqual(got, want) Checks that got and want are not equal True(got) Checks that got is true False(got) Checks that got is false Nil(got) Checks that got is nil NotNil(got) Checks that got is not nil ErrorIs(got, want) Checks that got is an error that wraps or equals want ErrorAs(got, target) Checks that got is an error that can be assigned to target via errors.As MatchesRegexp(got, pattern) Checks that got matches the regex pattern Between these nine functions, I'm able to easily do the vast majority of the checks that I want in my tests. Here are some examples from a web application that I'm currently working on: assert.Equal(t, w.StatusCode, http.StatusTeapot) assert.Equal(t, w.Header().Get("X-Custom-Header"), "custom-value") assert.NotEqual(t, updatedSession.token, originalSession.token) assert.True(t, defaultShutdownPeriod > defaultWriteTimeout) assert.True(t, strings.Contains(buf.String(), "level=ERROR")) assert.False(t, strings.Contains(string(decodedCookieValue), "this is a test value")) assert.Nil(t, err) assert.ErrorIs(t, err, sql.ErrNoRows) assert.MatchesRegexp(t, user.HashedPassword, `^\$2a\$12\$[./0-9A-Za-z]{53}$`) From the perspective of someone reading the code, I think it's quite easy to understand what these assertions are checking — even if you've never seen them before. And this might be personal preference, but when writing tests I actually prefer having only a small number of basic assertion functions to remember and pick from, rather than lots of very specific ones. If there is a complex check, which can't be done in a single line as part of the function call, I normally create an additional function and use it in conjunction with the True or False assertions. For example, when testing a web application, I will sometimes want to check if an HTML response body contains a specific HTML node (based on a CSS selector), so I will make a containsHTMLNode() function and then use it in my tests like this: assert.True(t, containsHTMLNode(t, res.Body, `meta[name="page"][content="home"]`)) assert.True(t, containsHTMLNode(t, res.Body, `form[method="POST"][action="/login"]`)) In theory, these assertion helpers could be reduced further. For example, the Nil(got) and NotNil(got) functions could be dropped in favour of using Equal(got, nil) and NotEqual(got, nil). Or MatchesRegexp(got, pattern) could be dropped in favour of using True() to check that a got value matches a specific regexp pattern. But these are checks I use often enough that I like having a specific assertion function for them. Go back to Anton's post for a moment, he effectively combines the assert.Nil(), assert.NotNil(), assert.ErrorIs() and assert.ErrorAs() functions that I have into a single AssertErr() function. The exact kind of check that is carried out by AssertErr() depends on what arguments you pass, or don't pass, to it. However, I prefer assertion functions to be responsible for checking one specific thing. I think it's less prone to mistakes, as well as clearer for a reader exactly what is being checked. Overall, I'm happy to have a few more assertion functions in exchange for some extra convenience, clarity and precision. Here's the complete code that I'm currently using for those functions: package assert import ( "errors" "reflect" "regexp" "testing" ) func Equal[T any](t *testing.T, got, want T) { t.Helper() if !isEqual(got, want) { t.Errorf("got: %v; want: %v", got, want) } } func NotEqual[T any](t *testing.T, got, want T) { t.Helper() if isEqual(got, want) { t.Errorf("got: %v; expected values to be different", got) } } func True(t *testing.T, got bool) { t.Helper() if !got { t.Errorf("got: false; want: true") } } func False(t *testing.T, got bool) { t.Helper() if got { t.Errorf("got: true; want: false") } } func Nil(t *testing.T, got any) { t.Helper() if !isNil(got) { t.Errorf("got: %v; want: nil", got) } } func NotNil(t *testing.T, got any) { t.Helper() if isNil(got) { t.Errorf("got: nil; want: non-nil") } } func ErrorIs(t *testing.T, got, want error) { t.Helper() if !errors.Is(got, want) { t.Errorf("got: %v; want: %v", got, want) } } func ErrorAs(t *testing.T, got error, target any) { t.Helper() if got == nil { t.Errorf("got: nil; want assignable to: %T", target) return } if !errors.As(got, target) { t.Errorf("got: %v; want assignable to: %T", got, target) } } func MatchesRegexp(t *testing.T, got, pattern string) { t.Helper() matched, err := regexp.MatchString(pattern, got) if err != nil { t.Fatalf("unable to parse regexp pattern %s: %s", pattern, err.Error()) return } if !matched { t.Errorf("got: %q; want to match %q", got, pattern) } } func isEqual[T any](got, want T) bool { if isNil(got) && isNil(want) { return true } if equalable, ok := any(got).(interface{ Equal(T) bool }); ok { return equalable.Equal(want) } return reflect.DeepEqual(got, want) } func isNil(v any) bool { if v == nil { return true } rv := reflect.ValueOf(v) switch rv.Kind() { case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: return rv.IsNil() } return false } Are assertion functions an anti-pattern? As I mentioned at the start of this post, the Go Wiki says that using assert packages should be avoided. It starts with this example of some 'bad' test code: assert.IsNotNil(t, "obj", obj) assert.StringEq(t, "obj.Type", obj.Type, "blogPost") assert.IntEq(t, "obj.Comments", obj.Comments, 2) assert.StringNotEq(t, "obj.Body", obj.Body, "") And suggests this as a 'good' alternative: if obj == nil || obj.Type != "blogPost" || obj.Comments != 2 || obj.Body == "" { t.Errorf("AddPost() = %+v", obj) } Let's quickly run through the arguments in the Go Wiki for these approaches being good and bad. [The bad code] either stops the test early (if assert calls t.Fatalf or panic) or omits interesting information about what the test got right There are packages out there (such as testify/require) that will stop a test early on the first assertion failure, and when they do you lose information about what subsequent checks would have passed. But not all of them do this, and if you make your own helpers for test assertions, you control what they do. You can call t.Errorf() to record the failure and continue the test if you want to. [The bad code] also forces the assert package to create a whole new sub-language instead of reusing the existing programming language (Go itself) I think this is a valid point and worth keeping in mind. Sometimes it can be frustrating to have to learn how a third-party package works, and to remember its API and syntax. And if the package is used in a project that lots of people work on, you're forcing all of them to spend the time to learn it. Sometimes it's easier to just read and write Go code that uses the standard library — even if it means you end up with more lines of code. But that said, I'm not sure that having a small number of basic assertion functions adds that much overhead... even for new people working on a codebase. Does having 3 assertion helpers like Anton, or 9 like me, really count as creating a whole new sub-language? Even if you argue that it does, it's a very small sub-language. Assert libraries make it too easy to write imprecise tests I think this is a good point in some — but not all — cases. If you're using a package that does different kinds of assertion checks in the same function (e.g. depending on the type of the argument passed to it, or the presence or not of a variadic argument) then yes, it's possible to see how it potentially increases the risk of bugs or a loss of precision in your tests. But if the assertion function checks one thing and one thing only, I don't see how it would be less precise. The assert.Equal() function that I use is a good example of this. It's imprecise because it checks whether both values are nil or whether both are the same based on an Equals() method or they are equal according to reflect.DeepEqual(). The ors introduce a subtle loss of precision that wouldn't exist if we were only checking one of those things. However, the go-cmp/cmp.Equal function, which the Go Wiki goes on to recommend using for equality checks, is imprecise in a similar way. I'm not sure that the assert.Equal() code above is really any worse in this sense. [Assert libraries] inevitably end up duplicating features already in the language, like expression evaluation, comparisons, sometimes even more. Yes. And I think this is why my preference is to use a small set of very basic assertions, like assert.Equal() and assert.True(). It means that I can write assertions like assert.True(t, len(mySlice) > 3) or assert.False(t, strings.Contains(name, "admin")) using the normal Go functions and operators. I don't get stuck down a rabbit hole implementing helpers like assert.SliceLengthGreaterThan() or assert.StringDoesNotContain() for every kind of check I need to do. On the flip side, the Go Wiki doesn't provide balance and mention the upsides of using assertion helpers, which is a shame. In terms of developer experience, I suspect that even the most hardened Gopher would agree that writing three lines of code like this: assert.Equal(t, w.StatusCode, http.StatusTeapot) assert.ErrorIs(t, err, sql.ErrNoRows) assert.True(t, defaultShutdownPeriod > defaultWriteTimeout) Is a faster and more enjoyable experience than writing the equivalent code like this: if w.StatusCode != http.StatusTeapot { t.Errorf("got %d; want %d", w.StatusCode, http.StatusTeapot) } if !errors.Is(err, sql.ErrNoRows) { t.Errorf("got error %q; want error to be or wrap %q", err.String(), sql.ErrNoRows) } if defaultShutdownPeriod Not only is the code shorter, but it takes away the cognitive overhead of having to write a failure message for each check. Which is both good and bad. I find it good because it frees up my brain to focus on arguably the most important thing — which is the logic of the test and what is being tested. When I'm thinking about test logic, I don’t want to get distracted trying to craft a perfect failure message, or having to look up for the 100th time whether it is got before want or want before got. Being able to type out assertions quickly, without losing my train of thought, is something that I really value and appreciate. And it's bad, because having useful and thoughtful failure messages can make debugging a problem easier. Getting a failure message that reads like this: --- FAIL: TestServerConfiguration (0.00s) — FAIL: TestServerConfiguration/Default_timeouts_are_reasonable (0.00s) server_test.go:24: default shutdown period 5s must be greater than default write timeout 10s Is much better than using an assert.True() helper and getting a failure message like this: --- FAIL: TestServerConfiguration (0.00s) — FAIL: TestServerConfiguration/Default_timeouts_are_reasonable (0.00s) server_test.go:22: got: false; want: true In this second example, all you have to go on to start debugging the failure is the file name and line number of the check — it doesn't even include the value that caused the check to fail. I do think this, in particular, is a genuine downside of the assert.True() and assert.False() helpers that I use. Summary I've found that the nine assertion helpers I shared above have worked well for me in a variety of projects — and they might work well for you too. But ultimately whether they are the right fit depends on your preferences, your team members, and the specific project. If you use a small collection of basic assertion functions like this, rather than a large third-party package, then I think that most of the criticisms that the Go Wiki makes of assert packages don't really apply. But you still need to accept that the failure messages printed by assertion functions may not be as helpful as a tailored, specific, failure message would be. On the plus side, they make for a good developer experience when writing tests. I particularly appreciate that they are quick to write and allow my mind to stay focused on the logic of what I'm testing. And on balance, anything that encourages me to write more tests is probably a good thing : )
Bob Nystrom
- The Value of ThingsJan 24, 2026
One of the reasons I write is to help me organize my own mind. I have a compulsive need to figure things out and I’ll lay awake at night shuffling sentences around in my head until it hangs together. Then I just have to try to get it all down in Markdown before it dissolves back into chaos. Like a lot of people these days, I am losing a lot of sleep over LLMs and generative AI. I mean that literally. I’ve had nights where I tossed and turned worrying about whether AI is going to destroy a career I love or wreck modern society until the sun comes up. For brevity’s sake, I’ll use “AI” here mostly to mean “generative AI”. The stuff that LLMs and products like ChatGPT do. I think it’s useful to distinguish that from other uses of machine learning like categorization and labeling. But here, all I care about is the generative stuff. I keep hoping I’ll sort it out in my head enough to reach some inner peace. I’m not saying I will literally figure it out, like, for the world. But I keep hoping I can at least figure out my own relationship with AI. I’m still not there, but I was able to pull one thread out of the tangled discourse around AI and get it to make sense in my head. I hope it will help you too. Or maybe this is all nonsense and you’ll point out the myriad ways in which I’m wrong. That’s a possibility, but I’m trying to be more courageous in my writing, so here I am. Anyway. Generative AI is about using machine learning to produce digital things. For AI to be a net good for the world, at the very least, the things it produces must have value. But what does it mean for a thing to have “value”? I say “at the very least” here because even if AI produces valuable things, it can still be a net bad. Societal effects and other externalities matter! But I haven’t untangled any of that stuff in my head yet, so I’ll ignore that here. Utility#utility The obvious answer is that a thing has value if it’s useful. If it solves some material problem in the world or at least creates some joy, that’s value. In the UX world, they call this “utility”: the thing a thing actually does. In UX, “utility” is used to distinguish from “usability”—how easy it is to do the thing. An apple has utility. You can eat it and it will give you some calories, some hydration, a little fiber. If it’s not an oxymoronically named Red Delicious, it might even bring you some delight. Material objects have a natural sort of utility from their physicality, but digital objects can have utility too. A while back, I took an interest in audio programming. I read a lot of articles online to learn some of the math and algorithms behind digital signal processing. That information is useful. Before I read those articles, I couldn’t program an FM synth. Now I can. That utility is just as real whether I got it from reading a classic article written by Julius O. Smith III, or from asking ChatGPT. There is a whole separate question about whether generative AI even can reliably produce good information. For this article, I’m presuming a steelman AI. Presume that we have the best version of generative AI that does do what it’s supposed to. Even then, how should we think about using it? In both the physical and digital worlds, utility really does matter. One possible anthropological definition of “technology” is anything that enables humans to more efficiently generate utility. It’s central to human progress. The reason I can go to a store and buy a bag of apples is because thousands of years of agriculture technology development means humans can very efficiently produce and transport food. And, you know, we need food to live. Food is cool. My career as a programming language designer is only possible because of all of the material I found online to learn how compilers and languages work. Hobbies like knitting, making music, and cooking are deeply important to me. They’re possible in large part from learning through YouTube videos. Those articles and videos were truly useful to me. Generative AI can help improve this. Here’s a concrete example: I got curious about how AI is affecting software jobs outside of my weird tech company bubble so I started poking around job listing sites. I stumbled onto a software engineer position working for the Washington Department of Ecology. The listing says: We’re looking for two journey-level developers who care about clean architecture, thrive in Agile teams, and see modern tooling—including AI-assisted development—as a way to work smarter, not riskier. You’ll collaborate with product, architecture, security, and platform engineering partners to design secure, accessible, scalable applications that replace decades of legacy complexity. … Use AI-Assisted Tools Responsibly and Effectively Leverage AI tools for boilerplate, test generation, and safe refactoring. Validate AI-generated code for accuracy, security, and maintainability — no blind trust, no shortcuts. Share best practices with teammates adopting new AI workflows. It’s hard for me to imagine a better morally justified use of AI for software development. This is a government office whose charter is to “protect and sustain healthy land, air, water, and climate in harmony with a strong economy”. They are literally working to make the world better for nature and humans. But as a government department, they are also really limited in their resources. If AI can make those two software engineers more productive, it can take the same tax dollars as input and produce a healthier world as output. That’s hard to argue against. Meaning#meaning When people get excited about AI and productivity, I think utility is what they have in mind. But utility is not the only kind of value a thing can have. A couple of years ago, I hand-knitted a scarf for my mother-in-law. The scarf does have utility. It’s a rectangle of fabric that keeps her neck warm. The Olympic Peninsula is a lovely corner of the country, but it’s often a chilly one. A scarf made out of wet tissue paper would not have the same utility. I could have just gone to the store and bought her a scarf. Were I the kind of robotic rational actor who thinks only about maximizing their utility function when not sipping pour-overs in some culty rationalist commune outside of SF, then I would know that the optimal strategy is to use my software engineer skills to maximally turn my labor into cash and then use that cash to buy a scarf. The scarf I hand-made took dozens of hours to make. At even a modest West Coast software engineer salary, that number of hours would buy an extremely nice scarf. Maybe not vicuña, but at least cashmere. Had I dropped a few hundred bucks on a scarf, would it have more value? Realistically, it would be a better scarf. Softer material, finer stitches. Certainly more fashionable. Fashion is social signalling. Social signals have utility and value too. You and I know the answer is “no”. As any parent who lovingly clips their kid’s objectively terrible art to the fridge knows, the value of a made thing is not entirely based on its merits. That scarf has value not because it’s a great scarf but because I chose to spend an irreplaceable fraction of my life making it. It’s a symbol of how much I care about her. In short, it has meaning. Meaning doesn’t make an object more useful. It’s not even transferrable. If my mother-in-law gave the scarf to you, its meaning would evaporate. But I bet that if you think about the objects that have the most value to you, your S-tier will be populated by things that excel mostly in meaning, not utility. The things that you would grab before running out of a burning house are the things with sentimental, not functional value. Source of meaning#source-of-meaning You kind of know the answer intuitively, but I’m trying to be clear in my thinking, so when I talk about objects having meaning… where does that meaning come from? Why do some objects have more meaning than others? I’ve spent hundreds of hours knitting over the past couple of years, mostly making things for friends and family. I’ve thought about this question a lot. I suspect that it comes down to spending time in service of another. Humans are sentient actors with agency and entire cognitive universes in our heads. But we are also animals made of meat and bones, and that flesh doesn’t last forever. Every one of us has a finite amount of things we will ever do. “Spend time” is one of those stock phrases that has been sucked dry of semantic flavor over the years, but there is a profound metaphor there. When you choose to use some of your time making a thing for someone, you don’t get that time back. It is spent. Choosing to spend a fraction of our most precious resource for someone else is the strongest signal we can send that that person matters to us. If we want to put our philospher hat on and play five whys, we might wonder why someone spending time on us is a thing we value. Why does someone burning their life’s candle warm our heart? I believe it’s because we’ve evolved to be a social species and we’re hard-wired to feel good when we think the tribe wants and values us. I’m in my late 40s. I hope I still have a lot of living left to do, but the odds are very good that I’m past the mid-point of my lifespan. An increasing fraction of conversations with my friends revolve around our various medical tribulations. I can feel the finiteness of life in my bones, quite literally. When I think about what I want to do with my remaining allotment of life, the answer that resonates is taking care of and making things for the people I love. Post-traumatic osteoarthritis sucks. Generating meaning#generating-meaning Generative AI, when wielded deftly, can be an amazing tool for creating things with utility faster and more easily than you ever could before. But it can’t generate meaning. The giant matrix of floating point numbers in a rack of GPUs in some data center does not love you. Another story: When my brother and I were growing up, we were really into movies. We made short videos (hilariously bad), learned how to do special effects make-up (actually tolerably good), and all sorts of stuff like that. We dreamed about growing up and becoming another pair of Hollywood brothers like the Zuckers or Coens. Many years later, as a birthday present, I wrote my brother a screenplay for a short horror film about a mythological siren. I toiled on it every night after the kids went to bed for weeks. It’s one of my favorite gifts. I don’t know if we’ll ever get a chance to shoot it. We live on opposite sides of the country and he can’t handle the gloom of Seattle any more than I can handle the politics of the South. It’s likely this screenplay has zero utility. But it still has a ton of meaning because I sweated every single word in that stack of 12-point Courier pages. Today, with the help of ChatGPT, I could probably put together a feature-length screenplay in a tenth of the time. It might even be an objectively better screenplay for a better movie. But because I made the screenplay in a tenth of the time thanks to ChatGPT’s help, it would hold only a tenth of the meaning for my brother. If my hypothesis that meaning comes from time sacrifice is true, then by making us more productive, AI eliminates meaning. I mean, if ChatGPT is good enough for the Duffer brothers—another great pair of filmaking bros—who am I to judge? Both kinds of value#both-kinds-of-value Most objects, digital or material, have a mixture of both kinds of meaning. My favorite gifts to give or receive are those rare ones that are both thoughtful and useful. Many years ago, I had a friend over for dinner. The next time I saw him, he handed me a rice cooker. It wasn’t a particularly expensive one or anything, but he saw the direction my cooking hobby was going and knew I would get a lot of use out of it. We’ve used that thing a hundred times since. An object carries with it the weight of time that was put into making it and has a certain heft from what you can do with it. I think of AI as a tool that can transmute that former kind of value into the latter. It drains some of the meaning out, but in return lets you make more things with utility for the same effort. There is nothing unique to AI about this. Any tool that increases efficiency has the same property. They make little knitting machines that make rows of stitches as easily as turning a crank. I could have made a scarf for my mother-in-law using that in a fraction of the time. Efficiency—the multiplier that determines how much effort is required to make an object—works sort of like a slider that lets us choose the ratio of utility and meaning the resulting thing has. Generative AI, assuming the claims are true, has the capability to radically move that slider. It can produce huge volumes of stuff with real utility. But that exact same ease implies that the results are all devoid of a sense of personal meaning. Two tangents#two-tangents I don’t want to take the idea that efficiency is a slider between meaningful-but-scarce and meaningless-plenty too far. It’s a rough model. There are at least two problems with it: First, it suggests that you can make an object infinitely meaningful by just making the process arbitrarily hard. I could have knitted that scarf using two leftover take-out Chinese bamboo chopsticks. In the dark. One-handed. That would certainly have required a greater sacrifice of time (and sanity) on my part. But there’s obviously a point of diminishing returns where people value the effort we put into making things but less so when we elect to be masochists. Also, when making things, we often derive personal joy from the process itself. Yes, I did sacrifice time to knit a scarf but… I like knitting. It wasn’t an entirely miserable toil or something. I got to play with yarn. The high level point is just that the more we automate the process of making a thing, the less of ourselves we put into it. And an object with less of ourselves in it is often valued less by the person who receives it. That’s all I’m saying. What kind of value do you want?#what-kind-of-value-do-you-want The tools we use to make things and the resulting productivity gives us a lever to control how much we prioritize getting a useful object out fast versus getting an object suffused with meaning. That suggests a way to decide when we should and shouldn’t use AI. If you just need a thing that does a thing, then its utility is what matters. I want the Washington Department of Ecology to use my very limited tax dollars as effectively as they can to keep the Pacific Northwest the enchanting natural marvel that it is. If they can do that better using AI, then by all means they should do so. If you want to make a thing that has some emotional resonance or some connection to other people—whether that be a loved one, an audience, or humanity as a whole—then you don’t want to suck out any of its meaning. I listen to a lot of electronic music while I work. In some sense, it is “utility music”. I just want a vibe to help me tune out the world and focus. I can’t even handle lyrics while I’m programming or writing. Erik Satie, the OG of ambient, described his compositions as “furniture music”. He described it in explicitly utilitarian terms: I think of it as melodious, softening the noises of the knives and forks at dinner, not dominating them, not imposing itself. It would fill up those heavy silences that sometime fall between friends dining together. It would spare them the trouble of paying attention to their own banal remarks. And at the same time it would neutralize the street noises which so indiscreetly enter into the play of conversation. To make such music would be to respond to a need. I have to admit that the genres I listen to are the ones most amenable to being automatically generated by machines. I literally do listen to generative ambient which has been a little corner of the music world since well before “generative” glommed onto the word “AI”. Even so, I still find my listening experience more gratifying when the music was made by someone who actually gave a shit about it. Even though the relationship between artist and listener is vague and indirect, that connection matters to me. I know that when I make music, the thing that helps me push through all of the many frustrations of producing is the hope—however tentative—that at the end of it all, one day someone might put on a track of mine and find their day improved by it being the temporary soundtrack to their life. Those moments of connection between artist and audience are what I live for as a creative person. For those, I can’t see generative AI as anything but harmful. Sometimes yes and sometimes no#sometimes-yes-and-sometimes-no Thinking about the things I make and consume and the kinds of value they contain helps me sort out some of my extremely conflicted feelings around AI. I want to emphasize that I’m only looking at this from the perspective of a single person’s individual use of AI. The global effects of AI are just as if not more important. But I haven’t sorted out my feelings around that to have any idea what to say here. Maybe I never will. But I can set that aside and imagine a world where I can run a generative AI completely locally on my machine. A world where I was the only person on Earth who had an AI. In that timeline, there would be almost no externalities to consider. Even then, I still want to be mindful of the consequences of using AI to help me make. In my own uses, I aim to focus on the places where I think AI can improve the efficiency of making utilitarian goods. And when I’m making something where the human connection and meaning are important, I will try to put as much of myself and as little of the machine into it as I can.
Dave Cheney
- Pop quiz: what time was it?Dec 18, 2025
Here’s a small quiz derived from some incorrect advice from an AI coding assistant. This program prints two timestamps; will they be a. Roughly the same time (ie, the same second)b. Roughly 10 seconds apartc. Something else Answer after the fold
- Pointer pop quizNov 27, 2025
Here’s a silly example extracted from real code. Does this program print true or false?