I have a file like this:

# Heading A

### Heading B1

lorem ipsum

### Heading B2 

lorem ipsum

and I want to reduce the header levels of B1 and B2 to look like this:

# Heading A

## Heading B1

lorem ipsum

## Heading B2 

lorem ipsum

I know how to increase markdown header levels from this answer.

I know this plugin can increase and decrease header levels but it's only for the original markdown flavor. I want to avoid plugins because I want to be able to do this for pandoc (and potentially other markdown flavors).

I searched Google but couldn't find anything helpful (for my level of knowledge).

How can I decrease the markdown header level in visual mode without a plugin?

Update December 10, 2019. Thanks for the answers! I've been using this custom command for a while in my init.vim (using Neovim now) and it seems to work well. Sorry, I can't remember clearly where I got this from. I think I combined apparat's answer with plasticboy's HeaderDecrease function.

command! -range HeaderIncrease <line1>,<line2>s/^#/##
command! -range HeaderDecrease <line1>,<line2>s/^##/#

You could simply search for two #s at the beginning of the line an replace it with one #.

Normal mode global replace:


and for visual mode:

  • Thanks for the answer! Doesn't seem to work in visual mode though. I receive the error: E488: Trailing characters. The exact command I'm running: '<,'>%s/^##/#/g. However, your answer does work in normal mode and on the entire file. Am I doing something wrong?
    – Allan Tsai
    Jul 28 '17 at 14:05
  • 1
    @Iota % means: 'use all the file as range' and '<,'> means ' "use visual selection as range" so, if you start a command from visual mode, the range is already provided for you and then, adding '%' raises The error E488: Trailing characters then, the command would be: '<,'>s/^##/#/ or '<,'>s/^##/#/g
    – rbernabe
    Jul 28 '17 at 14:15
  • @rbernabe Removing the % in visual mode works! Thanks!
    – Allan Tsai
    Jul 28 '17 at 14:37
  • @rbernabe thanks, I added this to the answer
    – apparat
    Jul 28 '17 at 14:59
  • Or even fewer keystrokes: :'<,'>s/^#//, which just removes the first # from any line that starts with a #. (Note that the g is unnecessary in your version, too).
    – Rich
    Jul 28 '17 at 15:44

@apparat gave you the correct answer, but I it can be made into filetype specific mappings for ease of use.

Put the following into ~/.vim/after/ftplugin/markdown.vim:

nnoremap <buffer> <silent> ]# :keeppatterns s/^/#/e<cr>
nnoremap <buffer> <silent> [# :keeppatterns s/^#//e<cr>
xnoremap <buffer> <silent> [# :<c-u>keeppatterns '<,'>g/^#/keeppatterns s/^#//<cr>
xnoremap <buffer> <silent> ]# :<c-u>keeppatterns '<,'>g/^#/keeppatterns s/^#/##/<cr>


  • This uses :keeppatterns so requires a never Vim (I think Vim 8+)
  • Uses a substitution so it will affect the & and g&.
  • Overwrites ]#/[# mappings. However these are rather useless in markdown

Without a plugin you'll likely have a very imperfect solution.

Indeed, while a leading # is a good sign for a header, it may unfortunately appear in other contexts: in code contexts. If in your markdown text you're likely to have snippets of bash script, Python, Perl, or many others, all the :[range]s/^# solutions will alter your snippets which you won't want.

If you only have very few headers or bash/perl/... comments, you can use the confirm flag of :substitute. Otherwise, well, you'll dream of something able to distinguish embedded code snippets from headers.

Recently, in my TOC generator I've used a state-machine to ignore code snippets. The code snippets identification is done with the following

let lines = getline(1, '$')
" First remove blocks of code
let blocks = map(copy(lines), 'v:val =~ "^\\s*```" ? (v:key + 1) : -1')
call filter(blocks, 'v:val > 0')

From there, [0, block[0]) defines the first block of line numbers without a code snippet. (block[1], blocks[2]) the second block and so on.From here, you'll want to intersect those lines numbers with the total range on which you'll want to apply the substitution.

 let blocks = [1] + blocks + [line('$')]
 let text_ranges = map(range(len(blocks)/2), '[blocks[2*v:val]-1, blocks[2*v:val+1]+1]')
 " Filter out ranges out side the selected range (a:firstline, a:lastline) -- untested
 call filter(text_ranges, 'v:val[1] >= a:firstline') 
 call filter(text_ranges, 'v:val[0] <= a:lastline')
 let text_ranges[0][0] = max(text_ranges[0][0], a:firstline)
 let text_ranges[-1][0] = min(text_ranges[-1][0], a:lastline)
 " And finally apply the substitutions (see the other answers)
 for range in text_ranges
     " todo: make sure range isn't empty
     " add one level
     exe range[0].','.range[1].'s/^##/#/'

As you can see, this is not trivial, this is one of the reasons we write plugins. BTW, we still have to wrap these lines in a range-function which is to be called from a command.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.