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, a @bibdesk_info block for document-level metadata, 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).
The Saved with string encoding line records the file’s character encoding, which is configurable in BibDesk’s preferences. bibdeskparser reads and writes UTF-8, BibDesk’s default; a database saved in any other encoding fails to load with a UnicodeDecodeError.
Document info (the @bibdesk_info block)
BibDesk attaches arbitrary key/value metadata to the database as a whole – not to any entry – through its “Document Info” panel (File → Document Info…). A database with document info stores it in a single @bibdesk_info block between the header comment and the @string definitions (a database whose document info is empty has no such block):
@bibdesk_info{document_info,
project = {qdyn},
funding = {DFG SFB 450}
}
The block has the syntactic shape of a BibTeX entry, but it is not one: document_info is a fixed pseudo-key that BibDesk writes always and ignores when reading, the keys are case-insensitive (like BibDesk’s entry fields), and – unlike entry fields and @string values – the values are written as plain Unicode, without TeX conversion. BibDesk also lays the block out differently from an entry, with the closing brace on its own line. Within BibDesk, the data is available to the cite-key and file-name autogeneration formats through the %i{Key} specifier and to export templates, and AppleScript exposes it as the document’s info records.
bibdeskparser exposes the stored data as the read-write mapping Library.info (info, set_info, and delete_info on the command line); the block does not appear as an entry under the key document_info. Keys are matched case-insensitively, mirroring BibDesk, and the data feeds the %i{Key} format specifier. As long as the info is not modified, the block survives any combination of other modifications and save() byte-for-byte, exactly as BibDesk wrote it; a modification through the mapping regenerates it (in BibDesk’s own layout, preserving the stored key order), and emptying the mapping removes it from the file.
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
A newly constructed Entry carries neither field; both are stamped the moment the entry is added to a Library, mirroring BibDesk’s stamp-on-creation behavior. (For a library in the plain format, which does no date bookkeeping, they are never created at all.) 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). To audit the stored paths against what is actually on disk, use check --files, which reports links that no longer resolve or whose spelling differs only in case from the file on disk.
A bdsk-file-N value need not be a base64 binary plist. A file written by export() with full=True records each attachment as its plain relative path (Bdsk-File-1 = {GrondPRA2009a.pdf}) instead. bibdeskparser reads such a value as a path-only attachment: it appears in entry.files like any other, and round-trips back to the same plain path. Renaming or replacing it through the Library methods above upgrades it to a full base64 attachment with a fresh 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. This does not include URLs. 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 jan … dec (“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 jan … dec 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: jan … dec 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.
These twelve are the canonical way to write a month field (btxdoc.tex: “You should use the standard three-letter abbreviation”). Writing the month out as literal text – month = {June}, or month = {06} – freezes one rendering into the database and takes the choice away from the style, which is free to typeset jun as June, Jun., Juni, or 6. The check command reports a literal month, and a bare reference to a macro outside the twelve, for the same reason it reports a literal journal value.
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.
Empty fields
A field with an empty value, like doi = {}, is valid BibTeX, and BibDesk parses it without complaint. It does not survive, though: whenever BibDesk saves a library, its serializer silently omits every field whose value is empty, so the field is deleted from the .bib file the next time the user saves in BibDesk, without any warning. An empty field therefore cannot carry durable information.
That rules out a marker convention that would otherwise be natural: an empty field meaning “this information was searched for and verified not to exist” (say, an article confirmed to have no DOI), as opposed to an absent field meaning “never checked”. Any tool sharing a .bib file with BibDesk would lose those markers on the first save. bibdeskparser consequently treats a defined-but-empty field as missing everywhere – in keys --missing, in the fetching commands, and in the audits – and the check command flags every empty field as a problem, since whatever it was meant to record is about to disappear.
The verified status “searched, this info does not exist” is recorded as membership in a known-missing group instead: a regular static group, named per field in the [known_missing] table of bibdeskparser.toml (configuration), e.g. "No Abstract" for abstract. Unlike an empty field, a static group survives every BibDesk save; BibDesk itself keeps its membership intact when citation keys change and when entries are deleted; the marker never travels into exported or shared entries, since it is not part of the entry; and membership can be inspected and changed directly in BibDesk’s group sidebar, by drag and drop. With the table configured, add_abstract and add_preprint maintain the groups declared for abstract and eprint automatically (recording clean no-finds, skipping members, unmarking when a value is stored), check accepts an article without a doi when the entry is in the group configured for doi and reports any entry whose membership in a declared group contradicts a non-empty value in that field, and keys --group selects the members. Groups declared for other fields are maintained by hand and get the contradiction audit; the configuration reference spells out what a declaration does per field. The how-to recipes for abstracts and preprints show the full workflow.
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.
The plain format of exported files
Everything on this page so far describes the database format: the layout BibDesk itself writes, with the header, the group @comment blocks, the bdsk-* fields, and the date bookkeeping. The files written by export() are different: plain BibTeX for citing from LaTeX, with none of those features. Such an exported file is a working file that evolves with a paper, and bibdeskparser maintains it as plain BibTeX.
A loaded .bib file is classified as being in the database format exactly if it contains any database-only content:
a BibDesk header comment,
a
@bibdesk_infoblock,any BibDesk group
@commentblock, orany
bdsk-*field (bdsk-file-Nattachments,bdsk-url-NURLs).
A file with none of these is plain. (The date-added/ date-modified fields are not markers: a plain file can legitimately contain them, e.g. in an entry pasted from a database.) A library created from scratch is always in the database format, so create keeps producing proper database files.
save() preserves the format it loaded: for a plain file, no header is synthesized, no groups block is written, no date fields are created, comments and @string definitions are preserved in place, and every stored field of every entry is written – unmodified entries in their stored field order, so a file created by export round-trips byte-identically. All commands that modify the .bib file thus work on exported files without converting them. A mutation that requires a database-only feature – creating a static group, attaching a file, or linking a URL – converts the library to the database format instead, with a FormatConversionWarning naming the trigger; the conversion is irreversible within the loaded instance, and the way back to a plain file is a fresh export.
Three options govern how a plain file is serialized: the value encoding (Unicode, or TeX-encoded), whether @string references are expanded, and the form preprints are exported in. Every export records them in a marker line written as the file’s very first block:
%% Created by BibDeskParser (unicode, preprints as unpublished).
The parenthesized list is a fixed vocabulary, parsed case-insensitively: unicode or TeX-encoded; strings expanded (present only for expand-strings exports); and preprints as unpublished/misc/article/stored. For a file without a marker (hand-written, or from another tool), each option is instead detected from the file’s content, choosing the behavior consistent with its observable state: the encoding is Unicode if any TeX-encodable value contains non-ASCII text (an all-ASCII file counts as TeX-encoded, the safe choice); strings count as expanded only if the file shows no macro usage at all (no @string definitions and no bare references, including bare month references like month = jan); and the preprint form is the stored entry type of the file’s preprint-only entries, when they are consistent. The options are resolved once, when the file is loaded; export --update (see How to manage an exported .bib file) reuses them for the entries it rewrites, and writes a marker recording them, so a marker-less file gains one on its first update. An export whose output includes bdsk-* fields (a full export of entries with attachments or linked URLs) is in the database format by the rules above, and therefore never gets a marker; if a file somehow contains both a marker and database-only content, the database content wins, with a warning.