BibDesk’s .bib Format

BibDesk stores its database as an ordinary BibTeX .bib file, but it leans on a handful of BibDesk-specific conventions on top of plain BibTeX: a header comment, extra bookkeeping fields, an encoding of linked files and URLs into otherwise-plain-looking fields, and @comment blocks for user-defined groups. The bibdeskparser library understands all of these and reproduces them exactly, so that a file loaded and saved again – without any changes – comes back byte-for-byte identical. This page walks through each of these features and the API that exposes it. For the full method and property reference, see the bibdeskparser API documentation, in particular the Library and Entry classes. The examples on this page operate on the example database shipped in the repository at tests/Refs/refs.bib, a BibDesk-authored library that exercises every feature described here.

The file header

Every .bib file BibDesk writes starts with a fixed comment block:

%% This BibTeX bibliography file was created using BibDesk.
%% http://bibdesk.sourceforge.net/


%% Created for Michael Goerz at 2026-07-18 16:02:00 -0400


%% Saved with string encoding Unicode (UTF-8)

The Created for line records who saved the file and when; BibDesk updates the timestamp on that line in place every time it saves, leaving everything else untouched. Library exposes this timestamp as timestamp, and save() advances it – but only when something in the library actually changed. Saving an unmodified library leaves the header (and the rest of the file) untouched:

>>> import warnings
>>> from bibdeskparser import Library
>>> bib = Library("tests/Refs/refs.bib")
>>> bib.timestamp
datetime.datetime(2026, 7, 18, 16, 2, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=72000)))
>>> bib.save()  # no changes made: the header timestamp is unchanged
>>> bib.timestamp
datetime.datetime(2026, 7, 18, 16, 2, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=72000)))

A library created from scratch has no header until it is first saved, at which point one is synthesized (using the current user’s full name, or an explicit creator= passed to the Library constructor).

date-added and date-modified

BibDesk stamps every entry with a date-added and a date-modified field the moment it is created, and updates date-modified on every edit. These two fields are bookkeeping, not bibliographic data, so they are not part of Entry’s dict-like interface; instead they are exposed as read-only datetime.datetime objects:

>>> entry = bib["GoerzNJP2014"]
>>> print(entry.date_added)
2026-07-18 07:49:28-04:00
>>> print(entry.date_modified)
2026-07-18 16:00:04-04:00

Any mutation of an Entry – setting or deleting a field, changing entry_type, adding or removing a URL with add_url() and friends, or a file-attachment change made through the owning Library – updates date-modified to the current time and marks the entry as modified since it was loaded. For ordinary fields and the entry type this mirrors what BibDesk itself does when a record is edited in its UI; for bdsk-file-N/bdsk-url-N changes BibDesk leaves date-modified untouched, and bibdeskparser deliberately updates it anyway, since the entry’s stored fields do change. That modified state is what save() uses to decide which entries need to be rewritten (and reordered into BibDesk’s field order) rather than copied through verbatim.

Linked files (bdsk-file-N fields)

When a PDF or other file is attached to an entry in BibDesk, it shows up in the .bib file as a bdsk-file-1, bdsk-file-2, … field (numbered in attachment order). The value looks like an opaque base64 blob because it is one: a binary property list containing the file’s path relative to the .bib file and, on macOS, a bookmark. BibDesk locates the file by its relative path first; when that fails (the file was moved or renamed), it falls back to the bookmark, which tracks the file by inode, and then repairs the stored path from it on its next save.

bibdeskparser decodes all of this for you. files presents the attachments as a plain list of relative path strings:

>>> entry.files
['GoerzNJP2014.pdf']

.files is read-only; attachments are modified through the owning Library (add_file(), replace_file(), unlink_file(), rename_file()). This is a direct consequence of how the attachments are stored: the paths in a bdsk-file-N field are relative to the .bib file, and only the Library knows where that file lives – an Entry on its own could not tell what GoerzNJP2014.pdf is relative to. Operating at the library level also lets these methods handle concerns that span entries: the same file can be linked from several entries, so deleting a file from disk must check all of them, and renaming a file on disk must update every entry that links it (see the How-To on managing attachments).

An attachment whose path is unchanged keeps its original bookmark data byte-for-byte (rather than dropping it or requiring you to regenerate it); only genuinely new paths get a freshly created bookmark (macOS only, requires the bibdeskparser[macos] extra). bibdeskparser never resolves a bookmark itself – it is opaque data that BibDesk uses to re-locate a moved or renamed file, preserved so BibDesk keeps working after bibdeskparser has touched the entry. entry.files always reflects the stored relative path, not a live lookup, so it does not change if the file is moved outside of bibdeskparser (or by BibDesk itself, resolving a bookmark).

Linked URLs (bdsk-url-N fields)

BibDesk also lets you link a URL to an entry, independently of the url bibliographic field; these become bdsk-url-1, bdsk-url-2, … fields, again numbered in order. Unlike linked files, there is no bookmark or path resolution involved: each field is just the URL string itself. Entry exposes them as urls, a read-only tuple of URL strings:

>>> entry.urls
('http://stacks.iop.org/1367-2630/16/i=5/a=055012',)

Because URLs are self-contained (no path resolution is needed), they can be managed directly on the Entry with add_url(), replace_url(), and remove_url() (or the equivalent add_url(), replace_url(), and remove_url()).

@string macros and journal abbreviations

BibTeX lets a field reference a named string instead of spelling out its value: @string{njp = {New J. Phys.}} defines the macro njp, and any later journal = njp (no braces or quotes) expands to New J. Phys. wherever the entry is used. BibDesk’s own journal-abbreviation lists work this way, and its UI shows the same expanded text whether a field holds a macro reference or the literal string.

This creates one genuine ambiguity: a bare, unquoted field value that happens to look like a valid macro name (say, journal = prl) could be a macro reference, or it could be someone’s literal (if unusual) text that just happens to match a macro name. bibdeskparser resolves it the same way BibDesk does: a bare value is always treated as a macro reference, and its expansion (rather than the macro name itself) is what BibDesk would display – but through Entry’s dict interface, you get the macro name back in its normalized (lowercase) form, since that is the value you would need to write back:

>>> entry = bib["GoerzNJP2014"]
>>> entry["journal"]  # 'njp' -- the normalized macro name
'njp'
>>> bib.strings["njp"]  # 'New J. Phys.' -- its expansion
'New J. Phys.'

strings is a read-write view of the @string table: assign to it to define or redefine a macro, delete a key to remove one (rejected with a ValueError if any entry still references it), and use rename_string() to rename a macro everywhere it is used in one step. Assigning an invalid name (one that doesn’t follow BibDesk’s own naming rules – a subset of ASCII, no leading digit) raises a ValueError right away, with a message explaining what’s wrong.

Macro names are case-insensitive, matching BibDesk’s macro table: bibdeskparser stores them in their canonical lowercase form (mixed-case names in a hand-edited file – both @string{JAN = ...} definitions and bare month = JAN references – are lowercased on load), and every operation (strings lookups, deletion, and rename_string()) matches names case-insensitively. A hand-edited @string{JAN = ...} therefore overrides the built-in jan month macro, and a month = JAN field resolves against it, exactly as in BibDesk.

When you do want to store literal text that happens to look like a macro name – so that it round-trips as a quoted/braced string rather than a bare reference – wrap it in ValueString:

>>> from bibdeskparser import ValueString
>>> entry["journal"] = ValueString("prl")  # stored as literal text {prl}
>>> entry["journal"]  # still just reads back as 'prl'
'prl'

A plain str assigned to a field that happens to match a defined (or even undefined) macro name is instead stored as a bare reference, the same way BibDesk would read it back. To make that intent explicit – or to force bare-reference storage in code that cannot rely on the value’s shape – wrap the value in MacroString instead, the mirror image of ValueString.

Default macros

BibTeX itself has no built-in macros, but the standard .bst style files (plain.bst etc.) all define the twelve month macros jandec (“January” … “December”, or the abbreviated names in the abbreviating styles), plus a couple of journal abbreviations such as acmcs or ieeetr; biblatex’s biber likewise treats jandec as predefined (mapping them to month numbers). Writing month = jan without any @string definition is therefore standard, portable BibTeX.

BibDesk builds in exactly the twelve month macros – resolving to the full (localized) month names – and nothing else; the .bst journal abbreviations are not built in. bibdeskparser does the same: jandec are always defined, expanding to the full English month names. They resolve at the lowest priority and are not part of strings (nor ever written to the .bib file), but in every other respect they behave as macro references: month = jan does not count as an undefined macro, and a @string{jan = ...} definition in the file is an ordinary macro that overrides the built-in month name and round-trips like any other. A bare reference to a .bst journal abbreviation, by contrast, is undefined unless the .bib file defines it.

BibDesk Static Groups

BibDesk lets you organize entries into user-curated “static” groups (as opposed to smart groups, which are just saved searches). These are recorded in a single @comment block at the end of the .bib file, holding an Apple property list:

@comment{BibDesk Static Groups{
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" ...>
<plist version="1.0">
<array>
	<dict>
		<key>group name</key>
		<string>My Papers</string>
		<key>keys</key>
		<string>GoerzJPB2011,GoerzNJP2014,...</string>
	</dict>
	...
</array>
</plist>
}}

bibdeskparser decodes this into groups, a read-write dict-like mapping from each group name to the tuple of the group’s citation keys:

>>> sorted(bib.groups)
['Diploma', 'My Papers']
>>> bib.groups["Diploma"]
('Tannor2007', 'NielsenChuangCh10QEC', 'Evans1983', 'LapertPRA09')

Whole groups are created, replaced, or deleted through the mapping interface (bib.groups[name] = (key, ...), with an empty tuple for a new empty group, and del bib.groups[name]); individual keys are added or removed with add_to_group() / remove_from_group(). The values are always tuples, so a group’s membership can never be edited in place, behind the library’s back. This matters because group membership is visible from two sides – the mapping above, and each entry’s read-only groups tuple – and Library, as the sole point through which membership can change, keeps the two consistent at all times:

>>> entry.groups  # already in one group
('My Papers',)
>>> bib.add_to_group("Diploma", "GoerzNJP2014")
>>> entry.groups
('Diploma', 'My Papers')
>>> bib.groups["Numerics"] = ("GoerzSPP2019", "GoerzQ2022")
>>> bib["GoerzSPP2019"].groups
('My Papers', 'Numerics')
>>> del bib.groups["Numerics"]
>>> bib["GoerzSPP2019"].groups
('My Papers',)

The consistency extends to the entries themselves: deleting an entry from the library removes its citation key from every group, and renaming one with rekey() rewrites the key inside each group it belongs to, so the stored group data never accumulates dangling keys. Conversely, keys assigned to a group must belong to entries in the library (a KeyError otherwise); only keys already present in a group loaded from a .bib file are exempt, so a file with stale group data still loads and round-trips.

BibDesk Smart Groups

Besides static groups, BibDesk supports smart groups: saved searches that dynamically collect every entry matching a set of conditions, e.g. “all @article entries whose author contains ‘Goerz’”. A smart group is recorded in an @comment block of the same shape as the static groups, but its plist stores the group’s query – an array of conditions plus the AND/OR conjunction combining them – rather than a list of citation keys:

@comment{BibDesk Smart Groups{
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" ...>
<plist version="1.0">
<array>
	<dict>
		<key>conditions</key>
		<array>
			<dict>
				<key>comparison</key>
				<integer>2</integer>
				<key>key</key>
				<string>Author</string>
				<key>value</key>
				<string>Goerz</string>
				<key>version</key>
				<string>1</string>
			</dict>
			...
		</array>
		<key>conjunction</key>
		<integer>0</integer>
		<key>group name</key>
		<string>Goerz Articles</string>
	</dict>
</array>
</plist>
}}

bibdeskparser preserves this block verbatim: it survives any combination of modifications and save() byte-for-byte, exactly as BibDesk wrote it. Beyond that, smart groups are deliberately not supported: they do not appear in groups, and there is no API to evaluate one. A smart group is a live query against BibDesk’s search machinery, not a list of keys. Its conditions are typed – string, date, attachment count, boolean, rating, and more, each type with its own set of comparison operators (the comparison integer above; strings alone have ten, from “contains” to “smaller than”) – and date conditions include relative ones like “in the last two weeks”, which BibDesk re-evaluates continuously as time passes. Computing a smart group’s membership faithfully would mean reimplementing all of BibDesk’s matching semantics, and any divergence would silently disagree with what BibDesk displays for the same group. For ad-hoc queries from Python or the command line, use search() instead.

BibDesk’s external file groups and script groups are stored in analogous BibDesk URL Groups / BibDesk Script Groups @comment blocks, and are likewise preserved verbatim without being interpreted.

Keywords

BibTeX’s conventional keywords field – a comma-separated list of tags inside each entry – is how BibDesk populates its “Keywords” sidebar. bibdeskparser treats it as structured data rather than as an ordinary field: it is readable through the entry’s dict interface (entry["keywords"] returns the comma-joined string, and keywords appears in iteration and len), but not writable that way – entry["keywords"] = ... and del entry["keywords"] raise KeyError. Instead, each entry exposes keywords, a read-only tuple, and the library exposes keywords, a dict-like mapping from each keyword to the tuple of citation keys of the entries carrying it, mirroring .groups:

>>> bib["GoerzJPB2011"].keywords
('OCT', 'Quantum Gates', 'Ultracold Atoms')
>>> bib.keywords["Filtering"]
('LapertPRA09',)
>>> bib.add_to_keyword("Filtering", "GoerzJPB2011")
>>> bib.keywords["Filtering"]
('GoerzJPB2011', 'LapertPRA09')

Hiding the raw field is what makes this mapping trustworthy: since every keyword edit goes through the Library (add_to_keyword(), remove_from_keyword(), or assignment/deletion on bib.keywords), the mapping and each entry’s .keywords can never disagree.

The storage difference from groups shows up in three ways. First, a keyword exists only inside entries’ keywords fields, so an empty keyword cannot be represented: add_to_keyword creates a keyword implicitly with its first entry, and assigning () is equivalent to deleting it. Second, keyword edits change the affected entries’ stored fields, so they bump each entry’s date-modified and mark it as modified since it was loaded, whereas group edits only touch the groups @comment block. Third, keywords travel with an entry – copy() preserves them – while group membership belongs to the library and does not.

The keywords field is also always literal text: a value that happens to look like a macro name is never treated as a @string reference, so a one-word keyword is always written braced, never counts as a use of a macro of the same name, and is left alone when a macro is renamed. This matches BibDesk, whose keyword handling works on the plain field text.

Unicode and TeX-escaped characters

BibDesk’s UI always displays accented and special characters as plain Unicode, but when it writes the .bib file, some of those characters are TeX-escaped for compatibility with tools that expect ASCII BibTeX – e.g. Universit{\"a}t for Universität – while others that have no standard TeX equivalent (like π or ) are written out as literal Unicode either way. Which characters get escaped, and how, follows BibDesk’s own conversion tables exactly.

bibdeskparser hides this distinction completely: Entry’s dict interface always shows and accepts plain Unicode, converting to and from the on-disk TeX escaping transparently, so a field you set in Python and one loaded from a BibDesk-written file behave identically:

>>> bib["GoerzDiploma2010"]["school"]
'Freie Universität Berlin'

URL-like fields (url, and the bdsk-url-N fields) are the one exception: BibDesk stores and displays these verbatim, without TeX conversion, since escaping would corrupt the URL – bibdeskparser does the same.

Duplicate citation keys

BibDesk itself does not enforce unique citation keys: if a .bib file somehow ends up with two entries sharing the same key (for instance, after a manual edit or a merge gone wrong), BibDesk loads the file anyway, keeping only the first entry for each duplicated key. Rather than raise on such a file, bibdeskparser does the same, and reports the affected keys via duplicate_keys (along with a UserWarning at load time) so the situation is visible without blocking you from working with the rest of the file:

>>> bib.duplicate_keys  # the example database itself is clean
()
>>> with warnings.catch_warnings():
...     warnings.simplefilter("ignore")  # the duplicate-key warning
...     dup_bib = Library("tests/Refs/with_duplicates.bib")
>>> dup_bib.duplicate_keys
('GoerzSPP2019',)

Byte-exact round-tripping

Taken together, these behaviors add up to one guarantee: loading a BibDesk-authored .bib file with Library and saving it again without touching anything reproduces the original file byte-for-byte, including the header, comments, blank lines, and field ordering. The one exception: hand-edited mixed-case macro names (a @string{JAN = ...} definition, or a bare month = JAN reference) are normalized to their canonical lowercase form on load and written back that way. Once you do make changes, save() rewrites only the entries that were actually modified or newly added (in BibDesk’s canonical field order) and copies everything else through verbatim, and it only advances the header timestamp when something in the library actually changed. This is what lets bibdeskparser sit alongside BibDesk itself, editing the same file, without producing noisy diffs or clobbering data BibDesk would have preserved.